enforce x-forwarded-host when reverse-proxied;

if x-forwarded-for is present, then also require
x-forwarded-host and x-forwarded-proto

avoids displaying subtly-incorrect values on the connect-page
and instead shows blatantly-incorrect values ("example.com")

the headernames x-forwarded-host and x-forwarded-proto can
be configured with global-options xf-host and xf-proto
This commit is contained in:
ed
2025-12-11 21:32:43 +00:00
parent 7d526eaba3
commit ad45de9441
2 changed files with 30 additions and 13 deletions

View File

@@ -1275,6 +1275,8 @@ def add_network(ap):
ap2.add_argument("--ll", action="store_true", help="include link-local IPv4/IPv6 in mDNS replies, even if the NIC has routable IPs (breaks some mDNS clients)")
ap2.add_argument("--rproxy", metavar="DEPTH", type=int, default=9999999, help="which ip to associate clients with; [\033[32m0\033[0m]=tcp, [\033[32m1\033[0m]=origin (first x-fwd, unsafe), [\033[32m-1\033[0m]=closest-proxy, [\033[32m-2\033[0m]=second-hop, [\033[32m-3\033[0m]=third-hop")
ap2.add_argument("--xff-hdr", metavar="NAME", type=u, default="x-forwarded-for", help="if reverse-proxied, which http header to read the client's real ip from")
ap2.add_argument("--xf-host", metavar="NAME", type=u, default="x-forwarded-host", help="if reverse-proxied, which http header to read the correct Host value from; this header must contain the server's external domain name")
ap2.add_argument("--xf-proto", metavar="NAME", type=u, default="x-forwarded-proto", help="if reverse-proxied, which http header to read the correct protocol value from; this header must contain either 'http' or 'https'")
ap2.add_argument("--xff-src", metavar="CIDR", type=u, default="127.0.0.0/8, ::1/128", help="list of trusted reverse-proxy CIDRs (comma-separated); only accept the real-ip header (\033[33m--xff-hdr\033[0m) and IdP headers if the incoming connection is from an IP within either of these subnets. Specify [\033[32mlan\033[0m] to allow all LAN / private / non-internet IPs. Can be disabled with [\033[32many\033[0m] if you are behind cloudflare (or similar) and are using \033[32m--xff-hdr=cf-connecting-ip\033[0m (or similar)")
ap2.add_argument("--ipa", metavar="CIDR", type=u, default="", help="only accept connections from IP-addresses inside \033[33mCIDR\033[0m (comma-separated); examples: [\033[32mlan\033[0m] or [\033[32m10.89.0.0/16, 192.168.33.0/24\033[0m]")
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]")

View File

@@ -150,7 +150,8 @@ NO_CACHE = {"Cache-Control": "no-cache"}
ALL_COOKIES = "k304 no304 js idxh dots cppwd cppws".split()
BADXFF = " due to dangerous misconfiguration (the http-header specified by --xff-hdr was received from an untrusted reverse-proxy)"
BADXFF = " due to dangerous misconfiguration (the http-header specified by --xff-hdr was received from an untrusted reverse-proxy, or --xf-host is incorrect)"
BADXFF2 = ". Some copyparty features are now disabled as a safety measure."
H_CONN_KEEPALIVE = "Connection: Keep-Alive"
H_CONN_CLOSE = "Connection: Close"
@@ -221,12 +222,11 @@ class HttpCli(object):
self.log_func = conn.log_func # mypy404
self.log_src = conn.log_src # mypy404
self.gen_fk = self._gen_fk if self.args.log_fk else gen_filekey
self.tls: bool = hasattr(self.s, "cipher")
self.tls = self.is_https = hasattr(self.s, "cipher")
self.is_vproxied = bool(self.args.R)
# placeholders; assigned by run()
self.keepalive = False
self.is_https = False
self.in_hdr_recv = True
self.headers: dict[str, str] = {}
self.mode = " " # http verb
@@ -390,9 +390,6 @@ class HttpCli(object):
self.keepalive = "close" not in zs and (
self.http_ver != "HTTP/1.0" or zs == "keep-alive"
)
self.is_https = (
self.headers.get("x-forwarded-proto", "").lower() == "https" or self.tls
)
self.host = self.headers.get("host") or ""
if not self.host:
if self.s.family == socket.AF_UNIX:
@@ -417,7 +414,7 @@ class HttpCli(object):
self.bad_xff = True
if self.args.rproxy != 9999999:
t = "global-option --rproxy %d could not be used (out-of-bounds) for the received header [%s]"
self.log(t % (self.args.rproxy, zso), c=3)
self.log(t % (self.args.rproxy, zso) + BADXFF2, c=3)
else:
zsl = [
" rproxy: %d if this client's IP-address is [%s]"
@@ -436,6 +433,7 @@ class HttpCli(object):
t += ' Note: if you are behind cloudflare, then this default header is not a good choice; please first make sure your local reverse-proxy (if any) does not allow non-cloudflare IPs from providing cf-* headers, and then add this additional global setting: "--xff-hdr=cf-connecting-ip"'
else:
t += ' Note: depending on your reverse-proxy, and/or WAF, and/or other intermediates, you may want to read the true client IP from another header by also specifying "--xff-hdr=SomeOtherHeader"'
t += BADXFF2
if "." in pip:
zs = ".".join(pip.split(".")[:2]) + ".0.0/16"
@@ -448,7 +446,23 @@ class HttpCli(object):
else:
self.ip = cli_ip
self.log_src = self.conn.set_rproxy(self.ip)
self.host = self.headers.get("x-forwarded-host") or self.host
try:
self.host = self.headers[self.args.xf_host]
self.is_https = len(self.headers[self.args.xf_proto]) == 5
except:
self.bad_xff = True
if self.args.xf_host not in self.headers:
self.host = "example.com"
t = 'got proxied request without header "%s" (global-option "xf-host"). This header must contain the true external "Host" value (the domain-name of the website). Either fix your reverse-proxy config to include this header, or change the copyparty global-option "xf-host" to another header-name to read this value from'
self.log(t % (self.args.xf_host,) + BADXFF2, 3)
if self.args.xf_proto not in self.headers:
t = 'got proxied request without header "%s" (global-option "xf-proto"). This header must contain either "http" or "https". Either fix your reverse-proxy config to include this header, or change the copyparty global-option "xf-proto" to another header-name to read this value from'
self.log(t % (self.args.xf_proto,) + BADXFF2, 3)
# the semantics of trusted_xff and bad_xff are different;
# trusted_xff is whether the connection came from a trusted reverseproxy,
# regardless of whether the client ip detection is correctly configured
# (the primary safeguard for idp is --idp-h-key)
trusted_xff = True
m = RE_HOST.search(self.host)
@@ -5717,17 +5731,18 @@ class HttpCli(object):
and (self.uname in vol.axs.uread or self.uname in vol.axs.upget)
}
bad_xff = hasattr(self, "bad_xff")
if bad_xff:
if hasattr(self, "bad_xff"):
allvols = []
t = "will not return list of recent uploads" + BADXFF
self.log(t, 1)
if self.avol:
raise Pebkac(500, t)
x = self.conn.hsrv.broker.ask(
"up2k.get_unfinished_by_user", self.uname, "" if bad_xff else self.ip
)
x = self.conn.hsrv.broker.ask("up2k.get_unfinished_by_user", self.uname, "")
else:
x = self.conn.hsrv.broker.ask(
"up2k.get_unfinished_by_user", self.uname, self.ip
)
zdsa: dict[str, Any] = x.get()
uret: list[dict[str, Any]] = []
if "timeout" in zdsa: