fix GHSA-m6hv-x64c-27mm: svg nohtml

This commit is contained in:
ed 2026-03-08 19:48:12 +00:00
parent 981a7cd9dd
commit 1c9f894e14
7 changed files with 73 additions and 33 deletions

View file

@ -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) * 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 * 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` * 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) * 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: safety profiles:

View file

@ -8,7 +8,7 @@ def say_no():
def main(cli, vn, rem): 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(): for chunk in say_no():
cli.s.sendall(chunk) cli.s.sendall(chunk)

View file

@ -1074,7 +1074,12 @@ class AuthSrv(object):
self.indent = "" self.indent = ""
self.is_lxc = args.c == ["/z/initcfg"] 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 = { self._vf0b = {
"oh_g": oh + "\r\n",
"oh_f": oh + "\r\n",
"cachectl": self.args.cachectl, "cachectl": self.args.cachectl,
"tcolor": self.args.tcolor, "tcolor": self.args.tcolor,
"du_iwho": self.args.du_iwho, "du_iwho": self.args.du_iwho,
@ -2634,8 +2639,17 @@ class AuthSrv(object):
if head_s and not head_s.endswith("\n"): if head_s and not head_s.endswith("\n"):
head_s += "\n" head_s += "\n"
zs = "X-Content-Type-Options: nosniff\r\n"
if "norobots" in vol.flags: if "norobots" in vol.flags:
head_s += META_NOBOTS 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") ico_url = vol.flags.get("ufavico")
if ico_url: if ico_url:

View file

@ -363,6 +363,7 @@ flagcats = {
"md_sba": "value of iframe allow-prop for markdown-sandbox", "md_sba": "value of iframe allow-prop for markdown-sandbox",
"lg_sba": "value of iframe allow-prop for *logue-sandbox", "lg_sba": "value of iframe allow-prop for *logue-sandbox",
"nohtml": "return html and markdown as text/html", "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_noacci": "hide account-info in the UI",
"ui_nocpla": "hide cpanel-link in the UI", "ui_nocpla": "hide cpanel-link in the UI",
"ui_nolbar": "hide link-bar in the UI", "ui_nolbar": "hide link-bar in the UI",

View file

@ -47,6 +47,7 @@ from .util import (
E_SCK_WR, E_SCK_WR,
HAVE_SQLITE3, HAVE_SQLITE3,
HTTPCODE, HTTPCODE,
SAFE_MIMES,
UTC, UTC,
VPTL_MAC, VPTL_MAC,
VPTL_OS, VPTL_OS,
@ -105,6 +106,7 @@ from .util import (
runhook, runhook,
s2hms, s2hms,
s3enc, s3enc,
safe_mime,
sanitize_fn, sanitize_fn,
sanitize_vpath, sanitize_vpath,
sendfile_kern, sendfile_kern,
@ -332,7 +334,6 @@ class HttpCli(object):
def run(self) -> bool: def run(self) -> bool:
"""returns true if connection can be reused""" """returns true if connection can be reused"""
self.out_headers = { self.out_headers = {
"Vary": self.args.http_vary,
"Cache-Control": "no-store, max-age=0", "Cache-Control": "no-store, max-age=0",
} }
@ -855,9 +856,6 @@ class HttpCli(object):
self.s.settimeout(self.args.s_tbody or None) 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: if "html_head_s" in vn.flags:
self.html_head += vn.flags["html_head_s"] self.html_head += vn.flags["html_head_s"]
@ -1072,6 +1070,7 @@ class HttpCli(object):
def send_headers( def send_headers(
self, self,
oh_k: str,
length: Optional[int], length: Optional[int],
status: int = 200, status: int = 200,
mime: Optional[str] = None, mime: Optional[str] = None,
@ -1114,7 +1113,11 @@ class HttpCli(object):
self.cbonk(self.conn.hsrv.gmal, zs, "cc_hdr", "Cc in out-hdr") self.cbonk(self.conn.hsrv.gmal, zs, "cc_hdr", "Cc in out-hdr")
raise Pebkac(999) raise Pebkac(999)
response.append(self.vn.flags[oh_k])
if self.args.ohead and self.do_log: if self.args.ohead and self.do_log:
zs = response.pop()[:-4]
response.extend(zs.split("\r\n"))
keys = self.args.ohead keys = self.args.ohead
if "*" in keys: if "*" in keys:
lines = response[1:] lines = response[1:]
@ -1126,8 +1129,8 @@ class HttpCli(object):
for zs in lines: for zs in lines:
hk, hv = zs.split(": ") hk, hv = zs.split(": ")
self.log("[O] {}: \033[33m[{}]".format(hk, hv), 5) self.log("[O] {}: \033[33m[{}]".format(hk, hv), 5)
response.append("\r\n")
response.append("\r\n")
try: try:
self.s.sendall("\r\n".join(response).encode("utf-8")) self.s.sendall("\r\n".join(response).encode("utf-8"))
except: except:
@ -1184,7 +1187,7 @@ class HttpCli(object):
except: except:
pass pass
self.send_headers(len(body), status, mime, headers) self.send_headers("oh_g", len(body), status, mime, headers)
try: try:
if self.mode != "HEAD": if self.mode != "HEAD":
@ -1389,7 +1392,7 @@ class HttpCli(object):
if res_path in RES: if res_path in RES:
ap = self.E.mod_ + res_path ap = self.E.mod_ + res_path
if bos.path.exists(ap) or bos.path.exists(ap + ".gz"): if bos.path.exists(ap) or bos.path.exists(ap + ".gz"):
return self.tx_file(ap) return self.tx_file("oh_g", ap)
else: else:
return self.tx_res(res_path) return self.tx_res(res_path)
@ -1403,7 +1406,7 @@ class HttpCli(object):
# return mimetype matching request extension # return mimetype matching request extension
self.ouparam["dl"] = res_path.split("/")[-1] self.ouparam["dl"] = res_path.split("/")[-1]
if bos.path.exists(ap) or bos.path.exists(ap + ".gz"): if bos.path.exists(ap) or bos.path.exists(ap + ".gz"):
return self.tx_file(ap) return self.tx_file("oh_g", ap)
else: else:
return self.tx_res(res_path) return self.tx_res(res_path)
@ -1717,7 +1720,10 @@ class HttpCli(object):
if zi.file_size >= maxsz: if zi.file_size >= maxsz:
raise Pebkac(404, "zip bomb defused") raise Pebkac(404, "zip bomb defused")
with zf.open(zi, "r") as fi: 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( sendfile_py(
self.log, self.log,
@ -1913,7 +1919,11 @@ class HttpCli(object):
chunksz = 0x7FF8 # preferred by nginx or cf (dunno which) chunksz = 0x7FF8 # preferred by nginx or cf (dunno which)
self.send_headers( self.send_headers(
None, 207, "text/xml; charset=" + enc, {"Transfer-Encoding": "chunked"} "oh_f",
None,
207,
"text/xml; charset=" + enc,
{"Transfer-Encoding": "chunked"},
) )
ap = "" ap = ""
@ -2120,7 +2130,7 @@ class HttpCli(object):
self.log("%s tried to lock %r" % (self.uname, "/" + self.vpath)) self.log("%s tried to lock %r" % (self.uname, "/" + self.vpath))
raise Pebkac(401, "authenticate") raise Pebkac(401, "authenticate")
self.send_headers(None, 204) self.send_headers("oh_f", None, 204)
return True return True
def handle_mkcol(self) -> bool: def handle_mkcol(self) -> bool:
@ -2222,7 +2232,7 @@ class HttpCli(object):
oh["Ms-Author-Via"] = "DAV" oh["Ms-Author-Via"] = "DAV"
# winxp-webdav doesnt know what 204 is # winxp-webdav doesnt know what 204 is
self.send_headers(0, 200) self.send_headers("oh_f", 0, 200)
return True return True
def handle_delete(self) -> bool: def handle_delete(self) -> bool:
@ -4525,11 +4535,11 @@ class HttpCli(object):
if self.do_log: if self.do_log:
self.log(logmsg) 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 return True
ret = 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( remains = sendfile_py(
self.log, self.log,
0, 0,
@ -4554,7 +4564,7 @@ class HttpCli(object):
return ret 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 status = 200
logmsg = "{:4} {} ".format("", self.req) logmsg = "{:4} {} ".format("", self.req)
logtail = "" logtail = ""
@ -4757,8 +4767,8 @@ class HttpCli(object):
else: else:
mime = guess_mime(cdis) mime = guess_mime(cdis)
if "nohtml" in self.vn.flags and "html" in mime: if mime not in SAFE_MIMES and "nohtml" in self.vn.flags:
mime = "text/plain; charset=utf-8" mime = safe_mime(mime)
self.out_headers["Accept-Ranges"] = "bytes" self.out_headers["Accept-Ranges"] = "bytes"
logmsg += unicode(status) + logtail logmsg += unicode(status) + logtail
@ -4767,7 +4777,7 @@ class HttpCli(object):
if self.do_log: if self.do_log:
self.log(logmsg) 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 return True
dls = self.conn.hsrv.dls dls = self.conn.hsrv.dls
@ -4799,7 +4809,7 @@ class HttpCli(object):
ret = True ret = True
with open_func(*open_args) as f: 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 sendfun = sendfile_kern if use_sendfile else sendfile_py
remains = sendfun( remains = sendfun(
@ -4832,7 +4842,7 @@ class HttpCli(object):
mime: str, mime: str,
) -> None: ) -> None:
vf = self.vn.flags 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] abspath: bytes = open_args[0]
sec_rate = vf["tail_rate"] sec_rate = vf["tail_rate"]
sec_max = vf["tail_tmax"] sec_max = vf["tail_tmax"]
@ -4972,7 +4982,7 @@ class HttpCli(object):
logmsg: str, logmsg: str,
) -> bool: ) -> bool:
M = 1048576 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_slp = self.args.s_wr_slp
wr_sz = self.args.s_wr_sz wr_sz = self.args.s_wr_sz
file_size = job["size"] file_size = job["size"]
@ -5185,7 +5195,9 @@ class HttpCli(object):
cdis = gen_content_disposition("%s.%s" % (fn, ext)) cdis = gen_content_disposition("%s.%s" % (fn, ext))
self.log(repr(cdis)) 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) 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"]})) # for f in fgen: print(repr({k: f[k] for k in ["vp", "ap"]}))
@ -5393,7 +5405,7 @@ class HttpCli(object):
if len(html) != 2: if len(html) != 2:
raise Exception("boundary appears in " + tpl) 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) logmsg += unicode(status)
if self.mode == "HEAD" or not do_send: if self.mode == "HEAD" or not do_send:
@ -6766,7 +6778,7 @@ class HttpCli(object):
add_og = True add_og = True
og_fn = "" 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" self.out_headers["X-Robots-Tag"] = "noindex, nofollow"
is_dir = stat.S_ISDIR(st.st_mode) is_dir = stat.S_ISDIR(st.st_mode)
@ -6838,7 +6850,7 @@ class HttpCli(object):
raise raise
if thp: if thp:
return self.tx_file(thp) return self.tx_file("oh_f", thp)
if th_fmt == "p": if th_fmt == "p":
raise Pebkac(404) raise Pebkac(404)
@ -6927,9 +6939,9 @@ class HttpCli(object):
if not add_og or not og_fn: if not add_og or not og_fn:
if st.st_size or "nopipe" in vn.flags: if st.st_size or "nopipe" in vn.flags:
return self.tx_file(abspath, None) return self.tx_file("oh_f", abspath, None)
else: 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: elif is_dir and not self.can_read:
if use_dirkey: if use_dirkey:
@ -7277,7 +7289,7 @@ class HttpCli(object):
return self.redirect( return self.redirect(
self.vpath + "/", flavor="redirecting to", use302=True 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: if icur:
mte = vn.flags.get("mte") or {} mte = vn.flags.get("mte") or {}

View file

@ -1154,7 +1154,7 @@ class Up2k(object):
ft = "\033[0;32m{}{:.0}" ft = "\033[0;32m{}{:.0}"
ff = "\033[0;35m{}{:.0}" ff = "\033[0;35m{}{:.0}"
fv = "\033[0;36m{}:\033[90m{}" 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()) fx = set(zs.split())
fd = vf_bmap() fd = vf_bmap()
fd.update(vf_cmap()) fd.update(vf_cmap())

View file

@ -413,6 +413,7 @@ IMPLICATIONS = [
["tftpvv", "tftpv"], ["tftpvv", "tftpv"],
["nodupem", "nodupe"], ["nodupem", "nodupe"],
["no_dupe_m", "no_dupe"], ["no_dupe_m", "no_dupe"],
["nohtml", "noscript"],
["sftpvv", "sftpv"], ["sftpvv", "sftpv"],
["smbw", "smb"], ["smbw", "smb"],
["smb1", "smb"], ["smb1", "smb"],
@ -474,7 +475,7 @@ MIMES = {
} }
def _add_mimes() -> None: def _add_mimes() -> set[str]:
# `mimetypes` is woefully unpopulated on windows # `mimetypes` is woefully unpopulated on windows
# but will be used as fallback on linux # but will be used as fallback on linux
@ -511,8 +512,11 @@ font ttc=collection
ext, mime = em.split("=") ext, mime = em.split("=")
MIMES[ext] = "{}/{}".format(k, mime) 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()} EXTS: dict[str, str] = {v: k for k, v in MIMES.items()}
@ -3503,6 +3507,13 @@ def guess_mime(
return ret 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]: def getalive(pids: list[int], pgid: int) -> list[int]:
alive = [] alive = []
for pid in pids: for pid in pids: