VYPR
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.

PackageAffected versionsPatched versions
copypartyPyPI
< 1.20.111.20.11

Affected products

1

Patches

1
1c9f894e149b

fix 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

News mentions

0

No linked articles in our index yet.