Moderate severityNVD Advisory· Published Mar 10, 2026· Updated Mar 11, 2026
Copyparty volflag `nohtml` did not block javascript in svg files
CVE-2026-30974
Description
Copyparty is a portable file server. Prior to v1.20.11., the nohtml config option, intended to prevent execution of JavaScript in user-uploaded HTML files, did not apply to SVG images. A user with write-permission could upload an SVG containing embedded JavaScript, which would execute in the context of whichever user opens it. This has been fixed in v1.20.11.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
copypartyPyPI | < 1.20.11 | 1.20.11 |
Affected products
1Patches
11c9f894e149bfix GHSA-m6hv-x64c-27mm: svg nohtml
7 files changed · +73 −33
bin/handlers/nooo.py+1 −1 modified@@ -8,7 +8,7 @@ def say_no(): def main(cli, vn, rem): - cli.send_headers(None, 404, "text/plain") + cli.send_headers("oh_f", None, 404, "text/plain") for chunk in say_no(): cli.s.sendall(chunk)
copyparty/authsrv.py+14 −0 modified@@ -1074,7 +1074,12 @@ def __init__( self.indent = "" self.is_lxc = args.c == ["/z/initcfg"] + oh = "X-Content-Type-Options: nosniff\r\n" + if self.args.http_vary: + oh += "Vary: %s\r\n" % (self.args.http_vary,) self._vf0b = { + "oh_g": oh + "\r\n", + "oh_f": oh + "\r\n", "cachectl": self.args.cachectl, "tcolor": self.args.tcolor, "du_iwho": self.args.du_iwho, @@ -2634,8 +2639,17 @@ def _reload(self, verbosity: int = 9) -> None: if head_s and not head_s.endswith("\n"): head_s += "\n" + zs = "X-Content-Type-Options: nosniff\r\n" if "norobots" in vol.flags: head_s += META_NOBOTS + zs += "X-Robots-Tag: noindex, nofollow\r\n" + if self.args.http_vary: + zs += "Vary: %s\r\n" % (self.args.http_vary,) + vol.flags["oh_g"] = zs + "\r\n" + + if "noscript" in vol.flags: + zs += "Content-Security-Policy: script-src 'none';\r\n" + vol.flags["oh_f"] = zs + "\r\n" ico_url = vol.flags.get("ufavico") if ico_url:
copyparty/cfg.py+1 −0 modified@@ -363,6 +363,7 @@ def vf_cmap() -> dict[str, str]: "md_sba": "value of iframe allow-prop for markdown-sandbox", "lg_sba": "value of iframe allow-prop for *logue-sandbox", "nohtml": "return html and markdown as text/html", + "noscript": "disable most javascript by use of CSP", "ui_noacci": "hide account-info in the UI", "ui_nocpla": "hide cpanel-link in the UI", "ui_nolbar": "hide link-bar in the UI",
copyparty/httpcli.py+40 −28 modified@@ -47,6 +47,7 @@ E_SCK_WR, HAVE_SQLITE3, HTTPCODE, + SAFE_MIMES, UTC, VPTL_MAC, VPTL_OS, @@ -105,6 +106,7 @@ runhook, s2hms, s3enc, + safe_mime, sanitize_fn, sanitize_vpath, sendfile_kern, @@ -332,7 +334,6 @@ def j2j(self, name: str) -> jinja2.Template: def run(self) -> bool: """returns true if connection can be reused""" self.out_headers = { - "Vary": self.args.http_vary, "Cache-Control": "no-store, max-age=0", } @@ -855,9 +856,6 @@ def run(self) -> bool: self.s.settimeout(self.args.s_tbody or None) - if "norobots" in vn.flags: - self.out_headers["X-Robots-Tag"] = "noindex, nofollow" - if "html_head_s" in vn.flags: self.html_head += vn.flags["html_head_s"] @@ -1072,6 +1070,7 @@ def _build_html_head(self, kv: dict[str, Any]) -> None: def send_headers( self, + oh_k: str, length: Optional[int], status: int = 200, mime: Optional[str] = None, @@ -1114,7 +1113,11 @@ def send_headers( self.cbonk(self.conn.hsrv.gmal, zs, "cc_hdr", "Cc in out-hdr") raise Pebkac(999) + response.append(self.vn.flags[oh_k]) + if self.args.ohead and self.do_log: + zs = response.pop()[:-4] + response.extend(zs.split("\r\n")) keys = self.args.ohead if "*" in keys: lines = response[1:] @@ -1126,8 +1129,8 @@ def send_headers( for zs in lines: hk, hv = zs.split(": ") self.log("[O] {}: \033[33m[{}]".format(hk, hv), 5) + response.append("\r\n") - response.append("\r\n") try: self.s.sendall("\r\n".join(response).encode("utf-8")) except: @@ -1184,7 +1187,7 @@ def reply( except: pass - self.send_headers(len(body), status, mime, headers) + self.send_headers("oh_g", len(body), status, mime, headers) try: if self.mode != "HEAD": @@ -1389,7 +1392,7 @@ def handle_get(self) -> bool: if res_path in RES: ap = self.E.mod_ + res_path if bos.path.exists(ap) or bos.path.exists(ap + ".gz"): - return self.tx_file(ap) + return self.tx_file("oh_g", ap) else: return self.tx_res(res_path) @@ -1403,7 +1406,7 @@ def handle_get(self) -> bool: # return mimetype matching request extension self.ouparam["dl"] = res_path.split("/")[-1] if bos.path.exists(ap) or bos.path.exists(ap + ".gz"): - return self.tx_file(ap) + return self.tx_file("oh_g", ap) else: return self.tx_res(res_path) @@ -1717,7 +1720,10 @@ def tx_zget(self, abspath) -> bool: if zi.file_size >= maxsz: raise Pebkac(404, "zip bomb defused") with zf.open(zi, "r") as fi: - self.send_headers(length=zi.file_size, mime=guess_mime(inner_path)) + mime = guess_mime(inner_path) + if mime not in SAFE_MIMES and "nohtml" in self.vn.flags: + mime = safe_mime(mime) + self.send_headers("oh_f", length=zi.file_size, mime=mime) sendfile_py( self.log, @@ -1913,7 +1919,11 @@ def handle_propfind(self) -> bool: chunksz = 0x7FF8 # preferred by nginx or cf (dunno which) self.send_headers( - None, 207, "text/xml; charset=" + enc, {"Transfer-Encoding": "chunked"} + "oh_f", + None, + 207, + "text/xml; charset=" + enc, + {"Transfer-Encoding": "chunked"}, ) ap = "" @@ -2120,7 +2130,7 @@ def handle_unlock(self) -> bool: self.log("%s tried to lock %r" % (self.uname, "/" + self.vpath)) raise Pebkac(401, "authenticate") - self.send_headers(None, 204) + self.send_headers("oh_f", None, 204) return True def handle_mkcol(self) -> bool: @@ -2222,7 +2232,7 @@ def handle_options(self) -> bool: oh["Ms-Author-Via"] = "DAV" # winxp-webdav doesnt know what 204 is - self.send_headers(0, 200) + self.send_headers("oh_f", 0, 200) return True def handle_delete(self) -> bool: @@ -4525,11 +4535,11 @@ def tx_res(self, req_path: str) -> bool: if self.do_log: self.log(logmsg) - self.send_headers(length=file_sz, status=status, mime=mime) + self.send_headers("oh_g", length=file_sz, status=status, mime=mime) return True ret = True - self.send_headers(length=file_sz, status=status, mime=mime) + self.send_headers("oh_g", length=file_sz, status=status, mime=mime) remains = sendfile_py( self.log, 0, @@ -4554,7 +4564,7 @@ def tx_res(self, req_path: str) -> bool: return ret - def tx_file(self, req_path: str, ptop: Optional[str] = None) -> bool: + def tx_file(self, oh_k: str, req_path: str, ptop: Optional[str] = None) -> bool: status = 200 logmsg = "{:4} {} ".format("", self.req) logtail = "" @@ -4757,8 +4767,8 @@ def tx_file(self, req_path: str, ptop: Optional[str] = None) -> bool: else: mime = guess_mime(cdis) - if "nohtml" in self.vn.flags and "html" in mime: - mime = "text/plain; charset=utf-8" + if mime not in SAFE_MIMES and "nohtml" in self.vn.flags: + mime = safe_mime(mime) self.out_headers["Accept-Ranges"] = "bytes" logmsg += unicode(status) + logtail @@ -4767,7 +4777,7 @@ def tx_file(self, req_path: str, ptop: Optional[str] = None) -> bool: if self.do_log: self.log(logmsg) - self.send_headers(length=upper - lower, status=status, mime=mime) + self.send_headers(oh_k, length=upper - lower, status=status, mime=mime) return True dls = self.conn.hsrv.dls @@ -4799,7 +4809,7 @@ def tx_file(self, req_path: str, ptop: Optional[str] = None) -> bool: ret = True with open_func(*open_args) as f: - self.send_headers(length=upper - lower, status=status, mime=mime) + self.send_headers(oh_k, length=upper - lower, status=status, mime=mime) sendfun = sendfile_kern if use_sendfile else sendfile_py remains = sendfun( @@ -4832,7 +4842,7 @@ def tx_tail( mime: str, ) -> None: vf = self.vn.flags - self.send_headers(length=None, status=status, mime=mime) + self.send_headers("oh_f", length=None, status=status, mime=mime) abspath: bytes = open_args[0] sec_rate = vf["tail_rate"] sec_max = vf["tail_tmax"] @@ -4972,7 +4982,7 @@ def tx_pipe( logmsg: str, ) -> bool: M = 1048576 - self.send_headers(length=upper - lower, status=status, mime=mime) + self.send_headers("oh_f", length=upper - lower, status=status, mime=mime) wr_slp = self.args.s_wr_slp wr_sz = self.args.s_wr_sz file_size = job["size"] @@ -5185,7 +5195,9 @@ def tx_zip( cdis = gen_content_disposition("%s.%s" % (fn, ext)) self.log(repr(cdis)) - self.send_headers(None, mime=mime, headers={"Content-Disposition": cdis}) + self.send_headers( + "oh_f", None, mime=mime, headers={"Content-Disposition": cdis} + ) fgen = vn.zipgen(vpath, rem, set(items), self.uname, False, dots, scandir) # for f in fgen: print(repr({k: f[k] for k in ["vp", "ap"]})) @@ -5393,7 +5405,7 @@ def tx_md(self, vn: VFS, fs_path: str) -> bool: if len(html) != 2: raise Exception("boundary appears in " + tpl) - self.send_headers(sz_md + len(html[0]) + len(html[1]), status) + self.send_headers("oh_g", sz_md + len(html[0]) + len(html[1]), status) logmsg += unicode(status) if self.mode == "HEAD" or not do_send: @@ -6766,7 +6778,7 @@ def tx_browser(self) -> bool: add_og = True og_fn = "" - if "b" in self.uparam: + if "b" in self.uparam and "norobots" not in vn.flags: self.out_headers["X-Robots-Tag"] = "noindex, nofollow" is_dir = stat.S_ISDIR(st.st_mode) @@ -6838,7 +6850,7 @@ def tx_browser(self) -> bool: raise if thp: - return self.tx_file(thp) + return self.tx_file("oh_f", thp) if th_fmt == "p": raise Pebkac(404) @@ -6927,9 +6939,9 @@ def tx_browser(self) -> bool: if not add_og or not og_fn: if st.st_size or "nopipe" in vn.flags: - return self.tx_file(abspath, None) + return self.tx_file("oh_f", abspath, None) else: - return self.tx_file(abspath, vn.get_dbv("")[0].realpath) + return self.tx_file("oh_f", abspath, vn.get_dbv("")[0].realpath) elif is_dir and not self.can_read: if use_dirkey: @@ -7277,7 +7289,7 @@ def tx_browser(self) -> bool: return self.redirect( self.vpath + "/", flavor="redirecting to", use302=True ) - return self.tx_file(ap) # is no-cache + return self.tx_file("oh_f", ap) # is no-cache if icur: mte = vn.flags.get("mte") or {}
copyparty/up2k.py+1 −1 modified@@ -1154,7 +1154,7 @@ def register_vpath( ft = "\033[0;32m{}{:.0}" ff = "\033[0;35m{}{:.0}" fv = "\033[0;36m{}:\033[90m{}" - zs = "bcasechk du_iwho emb_all emb_lgs emb_mds ext_th_d html_head html_head_d html_head_s ls_q_m put_name2 mv_re_r mv_re_t rm_re_r rm_re_t rw_edit_set srch_re_dots srch_re_nodot zipmax zipmaxn_v zipmaxs_v" + zs = "bcasechk du_iwho emb_all emb_lgs emb_mds ext_th_d html_head html_head_d html_head_s ls_q_m put_name2 mv_re_r mv_re_t rm_re_r rm_re_t oh_f oh_g rw_edit_set srch_re_dots srch_re_nodot zipmax zipmaxn_v zipmaxs_v" fx = set(zs.split()) fd = vf_bmap() fd.update(vf_cmap())
copyparty/util.py+13 −2 modified@@ -413,6 +413,7 @@ def umktrans(s1, s2): ["tftpvv", "tftpv"], ["nodupem", "nodupe"], ["no_dupe_m", "no_dupe"], + ["nohtml", "noscript"], ["sftpvv", "sftpv"], ["smbw", "smb"], ["smb1", "smb"], @@ -474,7 +475,7 @@ def umktrans(s1, s2): } -def _add_mimes() -> None: +def _add_mimes() -> set[str]: # `mimetypes` is woefully unpopulated on windows # but will be used as fallback on linux @@ -511,8 +512,11 @@ def _add_mimes() -> None: ext, mime = em.split("=") MIMES[ext] = "{}/{}".format(k, mime) + ptn = re.compile("html|script|tension|wasm|xml") + return {x for x in MIMES.values() if not ptn.search(x)} -_add_mimes() + +SAFE_MIMES = _add_mimes() EXTS: dict[str, str] = {v: k for k, v in MIMES.items()} @@ -3503,6 +3507,13 @@ def guess_mime( return ret +def safe_mime(mime: str) -> str: + if "text/" in mime or "xml" in mime: + return "text/plain; charset=utf-8" + else: + return "application/octet-stream" + + def getalive(pids: list[int], pgid: int) -> list[int]: alive = [] for pid in pids:
README.md+3 −1 modified@@ -2907,7 +2907,9 @@ some notes on hardening * set `--rproxy 0` *if and only if* your copyparty is directly facing the internet (not through a reverse-proxy) * cors doesn't work right otherwise * if you allow anonymous uploads or otherwise don't trust the contents of a volume, you can prevent XSS with volflag `nohtml` - * this returns html documents as plaintext, and also disables markdown rendering + * this returns html documents and svg images as plaintext, and also disables markdown rendering + * the `nohtml` volflag also enables `noscript` which, on its own, prevents *most* javascript from running; enabling just `noscript` without `nohtml` makes it probably-safe (see below) to view html and svg files, but `nohtml` is necessary to block javascript in markdown documents + * "probably-safe" because it relies on `Content-Security-Policy` so it depends on the reverseproxy to forward it, and the browser to understand it, but `nohtml` (the nuclear option) always works * when running behind a reverse-proxy, listen on a unix-socket for tighter access control (and more performance); see [reverse-proxy](#reverse-proxy) or [`--help-bind`](https://copyparty.eu/cli/#bind-help-page) safety profiles:
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-m6hv-x64c-27mmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-30974ghsaADVISORY
- github.com/9001/copyparty/commit/1c9f894e149b6be3cc7de81efc93a4ce4766e0e5ghsax_refsource_MISCWEB
- github.com/9001/copyparty/releases/tag/v1.20.11ghsax_refsource_MISCWEB
- github.com/9001/copyparty/security/advisories/GHSA-m6hv-x64c-27mmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.