From 741d781c184205d26bded5e6267ce98965d40031 Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 28 Jan 2023 00:59:04 +0000 Subject: [PATCH] add cors controls + improve preflight + pw header --- README.md | 28 ++++++++-- copyparty/__main__.py | 10 +++- copyparty/httpcli.py | 116 +++++++++++++++++++++++++++++++----------- copyparty/httpconn.py | 1 + copyparty/httpsrv.py | 5 ++ copyparty/svchub.py | 15 +++++- 6 files changed, 138 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d448a48e..e51dcded 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ try the **[read-only demo server](https://a.ocv.me/pub/demo/)** 👀 running fro * [client-side](#client-side) - when uploading files * [security](#security) - some notes on hardening * [gotchas](#gotchas) - behavior that might be unexpected + * [cors](#cors) - cross-site request config * [recovering from crashes](#recovering-from-crashes) * [client crashes](#client-crashes) * [frefox wsod](#frefox-wsod) - firefox 87 can crash during uploads @@ -1147,11 +1148,11 @@ interact with copyparty using non-browser clients * curl/wget: upload some files (post=file, chunk=stdin) * `post(){ curl -F act=bput -F f=@"$1" http://127.0.0.1:3923/?pw=wark;}` `post movie.mkv` - * `post(){ curl -b cppwd=wark -H rand:8 -T "$1" http://127.0.0.1:3923/;}` + * `post(){ curl -H pw:wark -H rand:8 -T "$1" http://127.0.0.1:3923/;}` `post movie.mkv` - * `post(){ wget --header='Cookie: cppwd=wark' --post-file="$1" -O- http://127.0.0.1:3923/?raw;}` + * `post(){ wget --header='pw: wark' --post-file="$1" -O- http://127.0.0.1:3923/?raw;}` `post movie.mkv` - * `chunk(){ curl -b cppwd=wark -T- http://127.0.0.1:3923/;}` + * `chunk(){ curl -H pw:wark -T- http://127.0.0.1:3923/;}` `chunk ` which runs for all visitors unless `--no-readme` +* anyone with move access can rename `some.html` to `.epilogue.html` so it'll run for all visitors unless either `--no-logues` or `--no-dot-ren` + + +## cors + +cross-site request config + +by default, except for `GET` and `HEAD` operations, all requests must either: +* not contain an `Origin` header at all +* or have an `Origin` matching the server domain +* or the header `PW` with your password as value + +cors can be configured with `--acao` and `--acam`, or the protections entirely disabled with `--allow-csrf` # recovering from crashes diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 50c4fff2..9c609fe9 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -818,6 +818,11 @@ def add_hooks(ap): ap2.add_argument("--xm", metavar="CMD", type=u, action="append", help="execute CMD on message") +def add_yolo(ap): + ap2 = ap.add_argument_group('yolo options') + ap2.add_argument("--allow-csrf", action="store_true", help="disable csrf protections; let other domains/sites impersonate you through cross-site requests") + + def add_optouts(ap): ap2 = ap.add_argument_group('opt-outs') ap2.add_argument("-nw", action="store_true", help="never write anything to disk (debug/benchmark)") @@ -852,6 +857,8 @@ def add_safety(ap, fk_salt): ap2.add_argument("--ban-404", metavar="N,W,B", type=u, default="no", help="hitting more than \033[33mN\033[0m 404's in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes (disabled by default since turbo-up2k counts as 404s)") ap2.add_argument("--aclose", metavar="MIN", type=int, default=10, help="if a client maxes out the server connection limit, downgrade it from connection:keep-alive to connection:close for MIN minutes (and also kill its active connections) -- disable with 0") ap2.add_argument("--loris", metavar="B", type=int, default=60, help="if a client maxes out the server connection limit without sending headers, ban it for B minutes; disable with [\033[32m0\033[0m]") + ap2.add_argument("--acao", metavar="V[,V]", type=u, default="*", help="Access-Control-Allow-Origin; list of origins (domains) to accept requests from. Default (*) allows requests from any site but will ignore cookies and http-auth (except for ?pw=hunter2)") + ap2.add_argument("--acam", metavar="V[,V]", type=u, default="GET,HEAD", help="Access-Control-Allow-Methods; list of methods to accept from offsite ('*' behaves like described in --acao)") def add_shutdown(ap): @@ -1027,9 +1034,10 @@ def run_argparse( add_webdav(ap) add_smb(ap) add_safety(ap, fk_salt) - add_hooks(ap) add_optouts(ap) add_shutdown(ap) + add_yolo(ap) + add_hooks(ap) add_ui(ap, retry) add_admin(ap) add_logging(ap) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index c1bd401c..90fea228 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -157,7 +157,7 @@ class HttpCli(object): self.trailing_slash = True self.out_headerlist: list[tuple[str, str]] = [] self.out_headers = { - "Access-Control-Allow-Origin": "*", + "Vary": "Origin, PW, Cookie", "Cache-Control": "no-store; max-age=0", } h = self.args.html_head @@ -346,11 +346,12 @@ class HttpCli(object): if zso: zsll = [x.split("=", 1) for x in zso.split(";") if "=" in x] cookies = {k.strip(): unescape_cookie(zs) for k, zs in zsll} - for kc, ku in (("cppws", "pw"), ("cppwd", "pw"), ("b", "b")): - if kc in cookies and ku not in uparam: - uparam[ku] = cookies[kc] + cookie_pw = cookies.get("cppws") or cookies.get("cppwd") + if "b" in cookies and "b" not in uparam: + uparam["b"] = cookies["b"] else: cookies = {} + cookie_pw = "" if len(uparam) > 10 or len(cookies) > 50: raise Pebkac(400, "u wot m8") @@ -363,25 +364,24 @@ class HttpCli(object): if ANYWIN: ok = ok and not relchk(self.vpath) - if not ok: + if not ok and (self.vpath != "*" or self.mode != "OPTIONS"): self.log("invalid relpath [{}]".format(self.vpath)) return self.tx_404() and self.keepalive - pwd = "" zso = self.headers.get("authorization") + bauth = "" if zso: try: zb = zso.split(" ")[1].encode("ascii") zs = base64.b64decode(zb).decode("utf-8") # try "pwd", "x:pwd", "pwd:x" - for zs in [zs] + zs.split(":", 1)[::-1]: - if self.asrv.iacct.get(zs): - pwd = zs + for bauth in [zs] + zs.split(":", 1)[::-1]: + if self.asrv.iacct.get(bauth): break except: pass - self.pw = uparam.get("pw") or pwd + self.pw = uparam.get("pw") or self.headers.get("pw") or bauth or cookie_pw self.uname = self.asrv.iacct.get(self.pw) or "*" self.rvol = self.asrv.vfs.aread[self.uname] self.wvol = self.asrv.vfs.awrite[self.uname] @@ -390,7 +390,10 @@ class HttpCli(object): self.gvol = self.asrv.vfs.aget[self.uname] self.upvol = self.asrv.vfs.apget[self.uname] - if self.pw: + if self.pw and ( + self.pw != cookie_pw or self.conn.freshen_pwd + 30 < time.time() + ): + self.conn.freshen_pwd = time.time() self.get_pwd_cookie(self.pw) if self.is_rclone: @@ -408,15 +411,22 @@ class HttpCli(object): ) = self.asrv.vfs.can_access(self.vpath, self.uname) try: - # getattr(self.mode) is not yet faster than this - if self.mode in ["GET", "HEAD"]: + cors_k = self._cors() + if self.mode in ("GET", "HEAD"): return self.handle_get() and self.keepalive - elif self.mode == "POST": + if self.mode == "OPTIONS": + return self.handle_options() and self.keepalive + + if not cors_k: + origin = self.headers.get("origin", "") + self.log("cors-reject {} from {}".format(self.mode, origin), 3) + raise Pebkac(403, "no surfing") + + # getattr(self.mode) is not yet faster than this + if self.mode == "POST": return self.handle_post() and self.keepalive elif self.mode == "PUT": return self.handle_put() and self.keepalive - elif self.mode == "OPTIONS": - return self.handle_options() and self.keepalive elif self.mode == "PROPFIND": return self.handle_propfind() and self.keepalive elif self.mode == "DELETE": @@ -635,6 +645,60 @@ class HttpCli(object): return True + def _cors(self) -> bool: + ih = self.headers + origin = ih.get("origin") + if not origin: + return True + + oh = self.out_headers + origin = re.sub(r"(:[0-9]{1,5})?/?$", "", origin.lower()) + methods = ", ".join(self.conn.hsrv.mallow) + good_origins = self.args.acao + [ + "{}://{}".format( + "https" if self.is_https else "http", + self.host.lower().split(":")[0], + ) + ] + if origin in good_origins: + good_origin = True + bad_hdrs = ("",) + else: + good_origin = False + bad_hdrs = ("", "pw") + + # '*' blocks all credentials (cookies, http-auth); + # exact-match for Origin is necessary to unlock those, + # however yolo-requests (?pw=) are always allowed + acah = ih.get("access-control-request-headers", "") + acao = (origin if good_origin else None) or ( + "*" if "*" in good_origins else None + ) + if self.args.allow_csrf: + acao = origin or acao or "*" # explicitly permit impersonation + acam = ", ".join(methods) # and all methods + headers + oh["Access-Control-Allow-Credentials"] = "true" + good_origin = True + else: + acam = ", ".join(self.args.acam) + # wash client-requested headers and roll with that + if "range" not in acah.lower(): + acah += ",Range" # firefox + req_h = acah.split(",") + req_h = [x.strip() for x in req_h] + req_h = [x for x in req_h if x.lower() not in bad_hdrs] + acah = ", ".join(req_h) + + if not acao: + return False + + oh["Access-Control-Allow-Origin"] = acao + oh["Access-Control-Allow-Methods"] = acam.upper() + if acah: + oh["Access-Control-Allow-Headers"] = acah + + return good_origin + def handle_get(self) -> bool: if self.do_log: logmsg = "{:4} {}".format(self.mode, self.req) @@ -1088,26 +1152,16 @@ class HttpCli(object): if self.do_log: self.log("OPTIONS " + self.req) - ret = { - "Allow": "GET, HEAD, POST, PUT, OPTIONS", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "*", - "Access-Control-Allow-Headers": "*", - } - - wd = { - "Dav": "1, 2", - "Ms-Author-Via": "DAV", - } + oh = self.out_headers + oh["Allow"] = ", ".join(self.conn.hsrv.mallow) if not self.args.no_dav: # PROPPATCH, LOCK, UNLOCK, COPY: noop (spec-must) - zs = ", PROPFIND, PROPPATCH, LOCK, UNLOCK, MKCOL, COPY, MOVE, DELETE" - ret["Allow"] += zs - ret.update(wd) + oh["Dav"] = "1, 2" + oh["Ms-Author-Via"] = "DAV" # winxp-webdav doesnt know what 204 is - self.send_headers(0, 200, headers=ret) + self.send_headers(0, 200) return True def handle_delete(self) -> bool: diff --git a/copyparty/httpconn.py b/copyparty/httpconn.py index d7a29cb0..023b7674 100644 --- a/copyparty/httpconn.py +++ b/copyparty/httpconn.py @@ -65,6 +65,7 @@ class HttpConn(object): self.ico: Ico = Ico(self.args) # mypy404 self.t0: float = time.time() # mypy404 + self.freshen_pwd: float = 0.0 self.stopping = False self.nreq: int = -1 # mypy404 self.nbyte: int = 0 # mypy404 diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index 6cd7bd15..d80c92f5 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -109,6 +109,11 @@ class HttpSrv(object): zs = os.path.join(self.E.mod, "web", "deps", "prism.js.gz") self.prism = os.path.exists(zs) + self.mallow = "GET HEAD POST PUT DELETE OPTIONS".split() + if not self.args.no_dav: + zs = "PROPFIND PROPPATCH LOCK UNLOCK MKCOL COPY MOVE" + self.mallow += zs.split() + if self.args.zs: from .ssdp import SSDPr diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 263206ee..a4d26e70 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -294,12 +294,25 @@ class SvcHub(object): al.zs_on = al.zs_on or al.z_on al.zm_off = al.zm_off or al.z_off al.zs_off = al.zs_off or al.z_off - for n in ("zm_on", "zm_off", "zs_on", "zs_off"): + ns = "zm_on zm_off zs_on zs_off acao acam" + for n in ns.split(" "): vs = getattr(al, n).split(",") vs = [x.strip() for x in vs] vs = [x for x in vs if x] setattr(al, n, vs) + ns = "acao acam" + for n in ns.split(" "): + vs = getattr(al, n) + vd = {zs: 1 for zs in vs} + setattr(al, n, vd) + + ns = "acao" + for n in ns.split(" "): + vs = getattr(al, n) + vs = [x.lower() for x in vs] + setattr(al, n, vs) + R = al.rp_loc if "//" in R or ":" in R: t = "found URL in --rp-loc; it should be just the location, for example /foo/bar"