From f81d80bcadfe5d4ed8ed7d1617de11e24b3f0db8 Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 1 Jan 2026 23:59:16 +0000 Subject: [PATCH] option to change the "pw" header/uparam name; useful to force basic-auth and such --- README.md | 1 + copyparty/__main__.py | 3 +++ copyparty/authsrv.py | 6 ++++-- copyparty/httpcli.py | 33 ++++++++++++++++++++++----------- docs/devnotes.md | 6 +++++- tests/util.py | 4 +++- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ba7d5195..d9a588cf 100644 --- a/README.md +++ b/README.md @@ -467,6 +467,7 @@ upgrade notes * can I link someone to a password-protected volume/file by including the password in the URL? * yes, by adding `?pw=hunter2` to the end; replace `?` with `&` if there are parameters in the URL already, meaning it contains a `?` near the end * if you have enabled `--usernames` then do `?pw=username:password` instead + * `?pw` can be disabled with `--pw-urlp=A` but this breaks support for many clients * how do I stop `.hist` folders from appearing everywhere on my HDD? * by default, a `.hist` folder is created inside each volume for the filesystem index, thumbnails, audio transcodes, and markdown document history. Use the `--hist` global-option or the `hist` volflag to move it somewhere else; see [database location](#database-location) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index bd7fc51d..ede8472b 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1286,6 +1286,7 @@ def add_network(ap): ap2.add_argument("--ipar", metavar="CIDR", type=u, default="", help="only accept connections from IP-addresses inside \033[33mCIDR\033[0m (comma-separated).\n └─this is reverseproxy-compatible; reads client-IP from 'X-Forwarded-For' if possible, with TCP/Network IP as fallback") ap2.add_argument("--rp-loc", metavar="PATH", type=u, default="", help="if reverse-proxying on a location instead of a dedicated domain/subdomain, provide the base location here; example: [\033[32m/foo/bar\033[0m]") ap2.add_argument("--cachectl", metavar="TXT", default="no-cache", help="default-value of the 'Cache-Control' response-header (controls caching in webbrowsers). Default prevents repeated downloading of the same file unless necessary (browser will ask copyparty if the file has changed). Examples: [\033[32mmax-age=604869\033[0m] will cache for 7 days, [\033[32mno-store, max-age=0\033[0m] will always redownload. (volflag=cachectl)") + ap2.add_argument("--http-vary", metavar="TXT", type=u, default="Origin, PW, Cookie", help="value of the 'Vary' response-header; a hint for caching proxies") ap2.add_argument("--http-no-tcp", action="store_true", help="do not listen on TCP/IP for http/https; only listen on unix-domain-sockets") if ANYWIN: ap2.add_argument("--reuseaddr", action="store_true", help="set reuseaddr on listening sockets on windows; allows rapid restart of copyparty at the expense of being able to accidentally start multiple instances") @@ -1350,6 +1351,8 @@ def add_auth(ap): ap2.add_argument("--idp-login-t", metavar="T", type=u, default="Login with SSO", help="the label/text for the idp-login button") ap2.add_argument("--idp-logout", metavar="L", type=u, default="", help="replace all logout-buttons with a link to URL \033[33mL\033[0m") ap2.add_argument("--auth-ord", metavar="TXT", type=u, default="idp,ipu", help="controls auth precedence; examples: [\033[32mpw,idp,ipu\033[0m], [\033[32mipu,pw,idp\033[0m], see --help-auth-ord") + ap2.add_argument("--pw-hdr", metavar="NAME", type=u, default="pw", help="lowercase name of password-header (NAME: foo); \033[1;31mWARNING:\033[0m Changing this will break support for many clients") + ap2.add_argument("--pw-urlp", metavar="NAME", type=u, default="pw", help="lowercase name of password url-param (?NAME=foo); \033[1;31mWARNING:\033[0m Changing this will break support for many clients") ap2.add_argument("--no-bauth", action="store_true", help="disable basic-authentication support; do not accept passwords from the 'Authenticate' header at all. NOTE: This breaks support for the android app") ap2.add_argument("--bauth-last", action="store_true", help="keeps basic-authentication enabled, but only as a last-resort; if a cookie is also provided then the cookie wins") ap2.add_argument("--ses-db", metavar="PATH", type=u, default=ses_db, help="where to store the sessions database (if you run multiple copyparty instances, make sure they use different DBs)") diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 273bfdd8..1120a078 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -2962,9 +2962,11 @@ class AuthSrv(object): pwds.extend([x.split(":", 1)[1] for x in pwds if ":" in x]) if pwds: if self.ah.on: - zs = r"(\[H\] pw:.*|[?&]pw=)([^&]+)" + zs = r"(\[H\] %s:.*|[?&]%s=)([^&]+)" + zs = zs % (self.args.pw_hdr, self.args.pw_urlp) else: - zs = r"(\[H\] pw:.*|=)(" + "|".join(pwds) + r")([]&; ]|$)" + zs = r"(\[H\] %s:.*|=)(" % (self.args.pw_hdr,) + zs += "|".join(pwds) + r")([]&; ]|$)" self.re_pwd = re.compile(zs) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index db18bb1f..d00ff2c5 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -328,7 +328,7 @@ class HttpCli(object): def run(self) -> bool: """returns true if connection can be reused""" self.out_headers = { - "Vary": "Origin, PW, Cookie", + "Vary": self.args.http_vary, "Cache-Control": "no-store, max-age=0", } @@ -682,7 +682,12 @@ class HttpCli(object): except: pass - self.pw = uparam.get("pw") or self.headers.get("pw") or bauth or cookie_pw + self.pw = ( + uparam.get(self.args.pw_urlp) + or self.headers.get(self.args.pw_hdr) + or bauth + or cookie_pw + ) self.uname = ( self.asrv.sesa.get(self.pw) or self.asrv.iacct.get(self.asrv.ah.hash(self.pw)) @@ -1198,6 +1203,7 @@ class HttpCli(object): return "" kv = {k: zs for k, zs in self.uparam.items() if k not in rm} + # no reason to consider args.pw_urlp if "pw" in kv: pw = self.cookies.get("cppws") or self.cookies.get("cppwd") if kv["pw"] == pw: @@ -1211,6 +1217,7 @@ class HttpCli(object): return "?" + "&".join(r) def ourlq(self) -> str: + # no reason to consider args.pw_urlp skip = ("pw", "h", "k") ret = [] for k, v in self.ouparam.items(): @@ -1274,12 +1281,15 @@ class HttpCli(object): proto = "https" if self.is_https else "http" good_origins = self.args.acao + ["%s://%s" % (proto, host)] - if "pw" in ih or re.sub(r"(:[0-9]{1,5})?/?$", "", origin) in good_origins: + if ( + self.args.pw_hdr in ih + or re.sub(r"(:[0-9]{1,5})?/?$", "", origin) in good_origins + ): good_origin = True bad_hdrs = ("",) else: good_origin = False - bad_hdrs = ("", "pw") + bad_hdrs = ("", self.args.pw_hdr) # '*' blocks auth through cookies / WWW-Authenticate; # exact-match for Origin is necessary to unlock those, @@ -1526,10 +1536,11 @@ class HttpCli(object): hits = idx.run_query(self.uname, [self.vn], uq, uv, False, False, nmax)[0] - if "pw" in self.ouparam and "nopw" not in self.ouparam: - zs = self.ouparam["pw"] - q_pw = "?pw=%s" % (quotep(zs),) - a_pw = "&pw=%s" % (quotep(zs),) + pwk = self.args.pw_urlp + if pwk in self.ouparam and "nopw" not in self.ouparam: + zs = self.ouparam[pwk] + q_pw = "?%s=%s" % (pwk, quotep(zs)) + a_pw = "&%s=%s" % (pwk, quotep(zs)) for i in hits: i["rp"] += a_pw if "?" in i["rp"] else q_pw else: @@ -1543,8 +1554,8 @@ class HttpCli(object): self.host, ) feed = baseurl + self.req[1:] - if "pw" in self.ouparam and self.ouparam.get("nopw") == "a": - feed = re.sub(r"&pw=[^&]*", "", feed) + if pwk in self.ouparam and self.ouparam.get("nopw") == "a": + feed = re.sub(r"&%s=[^&]*" % (pwk,), "", feed) if self.is_vproxied: baseurl += self.args.RS efeed = html_escape(feed, True, True) @@ -5308,7 +5319,7 @@ class HttpCli(object): defpw = "dave:hunter2" if self.args.usernames else "hunter2" vp = (self.uparam["hc"] or "").lstrip("/") - pw = self.ouparam.get("pw") or defpw + pw = self.ouparam.get(self.args.pw_urlp) or defpw if pw in self.asrv.sesa: pw = defpw diff --git a/docs/devnotes.md b/docs/devnotes.md index bf8290f3..652b0d33 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -155,7 +155,11 @@ there is a static salt for all passwords; * method `uPOST` = url-encoded post * `FILE` = conventional HTTP file upload entry (rfc1867 et al, filename in `Content-Disposition`) -authenticate using header `Cookie: cppwd=foo` or url param `&pw=foo` +clients can authenticate in the following ways; the first of these which is not blank will be used: +* url-param `&pw=foo` -- can be disabled with `--pw-urlp=A` (or renamed, if provided value is lowercase) +* then, header `PW: foo` -- can be disabled with `--pw-hdr=A` (or renamed, if provided value is lowercase) +* then, basic-auth -- can be disabled with `--no-bauth` +* then, depending on protocol, header `Cookie: cppwd=foo` on plaintext http, or header `Cookie: cppws=foo` on https ## read diff --git a/tests/util.py b/tests/util.py index 85209f3b..931d98f0 100644 --- a/tests/util.py +++ b/tests/util.py @@ -167,7 +167,7 @@ class Cfg(Namespace): ex = "ah_alg bname chdir chmod_f chpw_db doctitle df epilogues exit favico ipa ipar html_head html_head_d html_head_s idp_login idp_logout lg_sba lg_sbf log_date log_fk md_sba md_sbf name og_desc og_site og_th og_title og_title_a og_title_v og_title_i opds_exts preadmes prologues readmes shr tcolor textfiles txt_eol ufavico ufavico_h unlist vname xff_src zipmaxt R RS SR" ka.update(**{k: "" for k in ex.split()}) - ex = "apnd_who ban_403 ban_404 ban_422 ban_pw ban_pwc ban_url dont_ban cachectl rss_fmt_d rss_fmt_t spinner" + ex = "apnd_who ban_403 ban_404 ban_422 ban_pw ban_pwc ban_url dont_ban cachectl http_vary rss_fmt_d rss_fmt_t spinner" ka.update(**{k: "no" for k in ex.split()}) ex = "ext_th grp idp_h_usr idp_hm_usr ipr on403 on404 qr_file xac xad xar xau xban xbc xbd xbr xbu xiu xm" @@ -206,6 +206,8 @@ class Cfg(Namespace): mtp=[], put_ck="sha512", put_name="put-{now.6f}-{cip}.bin", + pw_hdr="pw", + pw_urlp="pw", mv_retry="0/0", rm_retry="0/0", rotf_tz="UTC",