diff --git a/README.md b/README.md index 9bd04620..8e45801c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/bin/handlers/nooo.py b/bin/handlers/nooo.py index 15287f76..3333fd59 100755 --- a/bin/handlers/nooo.py +++ b/bin/handlers/nooo.py @@ -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) diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index c959f3db..df501181 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -1074,7 +1074,12 @@ class AuthSrv(object): 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 @@ class AuthSrv(object): 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: diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 13b2c8b9..677c7408 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -363,6 +363,7 @@ flagcats = { "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", diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index b5b53599..7d1ac9dd 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -47,6 +47,7 @@ from .util import ( E_SCK_WR, HAVE_SQLITE3, HTTPCODE, + SAFE_MIMES, UTC, VPTL_MAC, VPTL_OS, @@ -105,6 +106,7 @@ from .util import ( runhook, s2hms, s3enc, + safe_mime, sanitize_fn, sanitize_vpath, sendfile_kern, @@ -332,7 +334,6 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): def send_headers( self, + oh_k: str, length: Optional[int], status: int = 200, mime: Optional[str] = None, @@ -1114,7 +1113,11 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): # 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 @@ class HttpCli(object): 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 {} diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 7aed0f4a..69a8986d 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -1154,7 +1154,7 @@ class Up2k(object): 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()) diff --git a/copyparty/util.py b/copyparty/util.py index 5a627171..89e8984e 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -413,6 +413,7 @@ IMPLICATIONS = [ ["tftpvv", "tftpv"], ["nodupem", "nodupe"], ["no_dupe_m", "no_dupe"], + ["nohtml", "noscript"], ["sftpvv", "sftpv"], ["smbw", "smb"], ["smb1", "smb"], @@ -474,7 +475,7 @@ MIMES = { } -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 @@ font ttc=collection 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: