From 6b26999e343f6daca608475a6783d610595055a9 Mon Sep 17 00:00:00 2001 From: lennondotw Date: Thu, 8 Oct 2026 15:50:24 +0800 Subject: [PATCH 1/2] fix(proxy): preserve client ports in forwarded Host headers Keep the incoming Host authority for default and custom locations, with the existing hostname fallback when Host is absent. Add a built-image regression that validates Host against Origin and Referer and run it after the CI image build. --- backend/templates/_location.conf | 2 +- .../etc/nginx/conf.d/include/proxy.conf | 2 +- docker/rootfs/etc/nginx/nginx.conf | 6 + scripts/ci/test-and-build | 2 + test/README.md | 12 ++ test/host-header-smoke.py | 167 ++++++++++++++++++ 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 test/host-header-smoke.py diff --git a/backend/templates/_location.conf b/backend/templates/_location.conf index a2ecb166d6..550a87e3be 100644 --- a/backend/templates/_location.conf +++ b/backend/templates/_location.conf @@ -1,7 +1,7 @@ location {{ path }} { {{ advanced_config }} - proxy_set_header Host $host; + proxy_set_header Host $forward_host; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; diff --git a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf index fe2c2f2132..fdba1bd7c5 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf @@ -1,5 +1,5 @@ add_header X-Served-By $host; -proxy_set_header Host $host; +proxy_set_header Host $forward_host; proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme; proxy_set_header X-Forwarded-Proto $x_forwarded_proto; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index bdba3b3055..471d225360 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -52,6 +52,12 @@ http { # Dynamically generated resolvers file include /etc/nginx/conf.d/include/resolvers[.]conf; + # Preserve the client authority, falling back when a request has no Host header. + map $http_host $forward_host { + default $http_host; + "" $host; + } + # Default upstream scheme map $host $forward_scheme { default http; diff --git a/scripts/ci/test-and-build b/scripts/ci/test-and-build index a0de34140a..fec906d606 100755 --- a/scripts/ci/test-and-build +++ b/scripts/ci/test-and-build @@ -28,3 +28,5 @@ docker build --pull --no-cache --compress \ --build-arg BUILD_DATE="$(date '+%Y-%m-%d %T %Z')" \ . echo -e "${BLUE}❯ ${GREEN}Building Complete${RESET}" + +python3 test/host-header-smoke.py "${IMAGE:-nginx-proxy-manager}:${BRANCH_LOWER:-unknown}-ci-${BUILD_NUMBER:-0000}" diff --git a/test/README.md b/test/README.md index 63a2453b72..57b9eb36a5 100644 --- a/test/README.md +++ b/test/README.md @@ -1,5 +1,17 @@ # Cypress Test Suite +## Host Header Regression Test + +Run `python3 test/host-header-smoke.py ` from the repository root. +The backend CI build runs this check against the image it builds. It creates a +disposable NPM container and renders a proxy host through the backend's normal +configuration generator, including a custom location. A synthetic HTTPS upstream +checks that the forwarded Host matches Origin and Referer, and echoes the request +method, path and body. Coverage includes HTTP and HTTPS, explicit default and +nonstandard ports, mixed-case names, IPv6 authorities, and HTTP/1.0 without Host. +All test traffic and published Docker ports use loopback; the container and its +anonymous volumes are removed on completion. + ## Running Locally ``` diff --git a/test/host-header-smoke.py b/test/host-header-smoke.py new file mode 100644 index 0000000000..a823b8977c --- /dev/null +++ b/test/host-header-smoke.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Check forwarded Host headers against a real upstream in a disposable image.""" + +import http.client +import json +import socket +import ssl +import subprocess +import sys +import time +import uuid + + +BACKEND = r''' +import http.server, json, ssl +from urllib.parse import urlsplit + +class Backend(http.server.BaseHTTPRequestHandler): + def do_GET(self): + host = self.headers.get("Host") + origin = self.headers.get("Origin") + referer = self.headers.get("Referer") + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + self.send_response(200) + if origin and (host != urlsplit(origin).netloc or host != urlsplit(referer).netloc): + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"") + else: + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"host": host, "origin": origin, "referer": referer, + "method": self.command, "path": self.path, + "body": body.decode()}).encode()) + + do_POST = do_GET + + def log_message(self, *args): + pass + +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain("/data/custom_ssl/npm-999/fullchain.pem", + "/data/custom_ssl/npm-999/privkey.pem") +server = http.server.ThreadingHTTPServer(("127.0.0.1", 19443), Backend) +server.socket = context.wrap_socket(server.socket, server_side=True) +server.serve_forever() +''' + +RENDER = r''' +import nginx from "./internal/nginx.js"; + +const host = { + id: 999, enabled: true, domain_names: ["host-port.example.test", "[::1]"], + forward_scheme: "https", forward_host: "127.0.0.1", forward_port: 19443, + certificate_id: 999, certificate: {provider: "other"}, access_list_id: 0, + ssl_forced: false, hsts_enabled: false, http2_support: false, + block_exploits: false, caching_enabled: false, allow_websocket_upgrade: false, + advanced_config: "", locations: [{path: "/custom/", advanced_config: "", + forward_scheme: "https", forward_host: "127.0.0.1", forward_port: 19443}] +}; +await nginx.generateConfig("proxy_host", host); +process.exit(0); +''' + + +def docker(*args, **kwargs): + return subprocess.check_output(["docker", *args], text=True, **kwargs).strip() + + +def request(port, tls=False, path="/", method="GET", headers=None, body=None): + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) + try: + if tls: + connection.sock = ssl._create_unverified_context().wrap_socket( + socket.create_connection(("127.0.0.1", port), timeout=5), + server_hostname="host-port.example.test") + connection.request(method, path, headers=headers or {}, body=body) + response = connection.getresponse() + return response.status, response.getheader("Content-Type"), response.read() + finally: + connection.close() + + +def check_image(image): + name = f"npm-host-header-smoke-{uuid.uuid4().hex[:10]}" + backend = None + checked = 0 + try: + docker("run", "-d", "--name", name, "-e", "IP_RANGES_FETCH_ENABLED=false", + "-p", "127.0.0.1::80", "-p", "127.0.0.1::443", "-p", "127.0.0.1::81", + "--mount", "type=volume,destination=/etc/letsencrypt", image) + ports = {p: int(docker("port", name, f"{p}/tcp").rsplit(":", 1)[1]) + for p in (80, 443, 81)} + deadline = time.monotonic() + 120 + while True: + try: + status, _, body = request(ports[81], path="/api/") + if status == 200 and json.loads(body).get("status") == "OK": + break + except (OSError, ValueError, http.client.HTTPException): + pass + if time.monotonic() >= deadline: + raise AssertionError("Manager health endpoint did not become ready") + time.sleep(1) + docker("exec", name, "mkdir", "-p", "/data/custom_ssl/npm-999") + docker("exec", name, "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", "/data/custom_ssl/npm-999/privkey.pem", + "-out", "/data/custom_ssl/npm-999/fullchain.pem", + "-days", "1", "-subj", "/CN=host-port.example.test", stderr=subprocess.DEVNULL) + backend = subprocess.Popen(["docker", "exec", name, "python3", "-S", "-c", BACKEND], + stdout=subprocess.DEVNULL) + subprocess.run(["docker", "exec", "-i", "-w", "/app", name, + "node", "--input-type=module"], input=RENDER, text=True, check=True) + docker("exec", name, "nginx", "-t") + docker("exec", name, "nginx", "-s", "reload") + time.sleep(1) + for tls, authorities in [ + (False, ["host-port.example.test", "host-port.example.test:80", + "host-port.example.test:232"]), + (True, ["host-port.example.test", "host-port.example.test:443", + "host-port.example.test:233", "HOST-PORT.EXAMPLE.TEST:233", + "[::1]:233"]), + ]: + scheme = "https" if tls else "http" + for authority in authorities: + for prefix in ("/", "/custom/"): + for method in ("GET", "POST"): + origin = f"{scheme}://{authority}" + referer = origin + "/settings" + path = prefix + "apply?one=1&two=2" + payload = "rule=example" if method == "POST" else None + try: + status, content_type, body = request( + ports[443 if tls else 80], tls, path, method, + {"Host": authority, "Origin": origin, "Referer": referer}, payload) + except (OSError, http.client.HTTPException) as error: + raise AssertionError((authority, path, method, str(error))) from error + assert status == 200 and content_type == "application/json", ( + authority, path, method, status, body) + assert json.loads(body) == {"host": authority, "origin": origin, + "referer": referer, "method": method, "path": path, + "body": payload or ""}, body + checked += 1 + # HTTP/1.0 permits no Host. An absolute target selects our generated host. + for prefix in ("/", "/custom/"): + with socket.create_connection(("127.0.0.1", ports[80]), timeout=5) as sock: + sock.sendall(f"GET http://host-port.example.test{prefix}fallback HTTP/1.0\r\n\r\n".encode()) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200, response.status + assert json.loads(response.read())["host"] == "host-port.example.test" + checked += 1 + print(json.dumps({"image": image, "cases": checked, "nginx": "passed", + "default_and_custom_locations": "passed", "http_and_https": "passed", + "origin_and_referer_validation": "passed", "missing_host_fallback": "passed"}), + flush=True) + except Exception: + subprocess.run(["docker", "logs", "--tail", "30", name], check=False) + raise + finally: + subprocess.run(["docker", "rm", "-fv", name], stdout=subprocess.DEVNULL, check=False) + if backend is not None: + backend.wait(timeout=10) + + +if __name__ == "__main__": + check_image(sys.argv[1]) From 3a455595e7ac80d7346b93438b4e1ff89e3d0fe1 Mon Sep 17 00:00:00 2001 From: lennondotw Date: Wed, 14 Oct 2026 14:48:22 +0800 Subject: [PATCH 2/2] fix(proxy): isolate cached assets by forwarded authority --- .../etc/nginx/conf.d/include/assets.conf | 2 +- test/README.md | 11 ++ test/host-header-smoke.py | 140 +++++++++++++----- 3 files changed, 119 insertions(+), 34 deletions(-) diff --git a/docker/rootfs/etc/nginx/conf.d/include/assets.conf b/docker/rootfs/etc/nginx/conf.d/include/assets.conf index 5a90beb8ae..9157d172d2 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/assets.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/assets.conf @@ -3,7 +3,7 @@ location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map| # use the public cache proxy_cache public-cache; - proxy_cache_key $host$request_uri; + proxy_cache_key $forward_host$request_uri; # ignore these headers for media proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires; diff --git a/test/README.md b/test/README.md index 57b9eb36a5..20be040e7b 100644 --- a/test/README.md +++ b/test/README.md @@ -56,3 +56,14 @@ The ESLint extension only works on JavaScript files by default, so add the follo "editor.formatOnSave": false, }, ``` + +## Forwarded Host image test + +Run `python3 test/host-header-smoke.py ` from the repository root. +The test waits for the manager, generated HTTP/HTTPS proxy host, both location +paths and its synthetic upstream. It verifies Host/Origin/Referer consistency, +request bodies and HTTP/1.0 fallback, then uses a dynamic upstream counter to +prove cache hits and isolation between client authorities on ports 232 and 233. +Requests run through `docker exec`, so a remote Docker daemon or containerized +CI agent needs no shared loopback interface or published ports. The CI image +build script invokes this test against the image it just built. diff --git a/test/host-header-smoke.py b/test/host-header-smoke.py index a823b8977c..a1397d8783 100644 --- a/test/host-header-smoke.py +++ b/test/host-header-smoke.py @@ -1,10 +1,8 @@ #!/usr/bin/env python3 """Check forwarded Host headers against a real upstream in a disposable image.""" -import http.client +import base64 import json -import socket -import ssl import subprocess import sys import time @@ -16,7 +14,11 @@ from urllib.parse import urlsplit class Backend(http.server.BaseHTTPRequestHandler): + requests = 0 + def do_GET(self): + Backend.requests += 1 + request_number = Backend.requests host = self.headers.get("Host") origin = self.headers.get("Origin") referer = self.headers.get("Referer") @@ -28,10 +30,13 @@ def do_GET(self): self.wfile.write(b"") else: self.send_header("Content-Type", "application/json") + self.send_header("X-Upstream-Request", str(request_number)) self.end_headers() - self.wfile.write(json.dumps({"host": host, "origin": origin, "referer": referer, - "method": self.command, "path": self.path, - "body": body.decode()}).encode()) + response = {"host": host, "origin": origin, "referer": referer, + "method": self.command, "path": self.path, "body": body.decode()} + if self.path.endswith(".js"): + response["request_number"] = request_number + self.wfile.write(json.dumps(response).encode()) do_POST = do_GET @@ -52,9 +57,9 @@ def log_message(self, *args): const host = { id: 999, enabled: true, domain_names: ["host-port.example.test", "[::1]"], forward_scheme: "https", forward_host: "127.0.0.1", forward_port: 19443, - certificate_id: 999, certificate: {provider: "other"}, access_list_id: 0, + certificate_id: 999, certificate: {id: 999, provider: "other"}, access_list_id: 0, ssl_forced: false, hsts_enabled: false, http2_support: false, - block_exploits: false, caching_enabled: false, allow_websocket_upgrade: false, + block_exploits: false, caching_enabled: true, allow_websocket_upgrade: false, advanced_config: "", locations: [{path: "/custom/", advanced_config: "", forward_scheme: "https", forward_host: "127.0.0.1", forward_port: 19443}] }; @@ -67,18 +72,69 @@ def docker(*args, **kwargs): return subprocess.check_output(["docker", *args], text=True, **kwargs).strip() -def request(port, tls=False, path="/", method="GET", headers=None, body=None): - connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) - try: - if tls: +# Keep all traffic on the tested container's loopback interface so this also +# works when the CI agent talks to a Docker daemon on another host. +REQUEST = r''' +import base64, http.client, json, socket, ssl, sys +args = json.load(sys.stdin) +connection = None +sock = None +try: + if args["http10"]: + sock = socket.create_connection(("127.0.0.1", 80), timeout=5) + target = "http://host-port.example.test" + args["path"] + sock.sendall(f"GET {target} HTTP/1.0\r\n\r\n".encode()) + response = http.client.HTTPResponse(sock) + response.begin() + else: + port = 443 if args["tls"] else 80 + if args["admin"]: + port = 81 + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) + if args["tls"]: connection.sock = ssl._create_unverified_context().wrap_socket( socket.create_connection(("127.0.0.1", port), timeout=5), server_hostname="host-port.example.test") - connection.request(method, path, headers=headers or {}, body=body) + connection.request(args["method"], args["path"], headers=args["headers"], body=args["body"]) response = connection.getresponse() - return response.status, response.getheader("Content-Type"), response.read() - finally: + print(json.dumps({"status": response.status, "headers": dict(response.getheaders()), + "body": base64.b64encode(response.read()).decode()})) +finally: + if connection is not None: connection.close() + if sock is not None: + sock.close() +''' + + +def request(container, tls=False, path="/", method="GET", headers=None, body=None, admin=False, http10=False): + payload = {"tls": tls, "path": path, "method": method, "headers": headers or {}, + "body": body, "admin": admin, "http10": http10} + try: + result = json.loads(docker("exec", "-i", container, "python3", "-S", "-c", REQUEST, + input=json.dumps(payload), stderr=subprocess.DEVNULL, timeout=15)) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise OSError("Request inside the tested container failed") from error + return result["status"], result["headers"], base64.b64decode(result["body"]) + + +def wait_for_generated_host(container): + deadline = time.monotonic() + 60 + while True: + try: + for tls in (False, True): + for prefix in ("/", "/custom/"): + path = prefix + "readiness" + status, headers, body = request(container, tls, path, + headers={"Host": "host-port.example.test:233"}) + data = json.loads(body) + assert status == 200 and headers.get("Content-Type") == "application/json" + assert data["host"] == "host-port.example.test:233" and data["path"] == path + return + except (OSError, ValueError, AssertionError, KeyError): + if time.monotonic() >= deadline: + raise AssertionError("Generated HTTP/HTTPS host and upstream did not become ready") + time.sleep(0.5) def check_image(image): @@ -87,17 +143,14 @@ def check_image(image): checked = 0 try: docker("run", "-d", "--name", name, "-e", "IP_RANGES_FETCH_ENABLED=false", - "-p", "127.0.0.1::80", "-p", "127.0.0.1::443", "-p", "127.0.0.1::81", "--mount", "type=volume,destination=/etc/letsencrypt", image) - ports = {p: int(docker("port", name, f"{p}/tcp").rsplit(":", 1)[1]) - for p in (80, 443, 81)} deadline = time.monotonic() + 120 while True: try: - status, _, body = request(ports[81], path="/api/") + status, _, body = request(name, path="/api/", admin=True) if status == 200 and json.loads(body).get("status") == "OK": break - except (OSError, ValueError, http.client.HTTPException): + except (OSError, ValueError): pass if time.monotonic() >= deadline: raise AssertionError("Manager health endpoint did not become ready") @@ -113,7 +166,7 @@ def check_image(image): "node", "--input-type=module"], input=RENDER, text=True, check=True) docker("exec", name, "nginx", "-t") docker("exec", name, "nginx", "-s", "reload") - time.sleep(1) + wait_for_generated_host(name) for tls, authorities in [ (False, ["host-port.example.test", "host-port.example.test:80", "host-port.example.test:232"]), @@ -130,12 +183,12 @@ def check_image(image): path = prefix + "apply?one=1&two=2" payload = "rule=example" if method == "POST" else None try: - status, content_type, body = request( - ports[443 if tls else 80], tls, path, method, + status, headers, body = request( + name, tls, path, method, {"Host": authority, "Origin": origin, "Referer": referer}, payload) - except (OSError, http.client.HTTPException) as error: + except OSError as error: raise AssertionError((authority, path, method, str(error))) from error - assert status == 200 and content_type == "application/json", ( + assert status == 200 and headers.get("Content-Type") == "application/json", ( authority, path, method, status, body) assert json.loads(body) == {"host": authority, "origin": origin, "referer": referer, "method": method, "path": path, @@ -143,16 +196,37 @@ def check_image(image): checked += 1 # HTTP/1.0 permits no Host. An absolute target selects our generated host. for prefix in ("/", "/custom/"): - with socket.create_connection(("127.0.0.1", ports[80]), timeout=5) as sock: - sock.sendall(f"GET http://host-port.example.test{prefix}fallback HTTP/1.0\r\n\r\n".encode()) - response = http.client.HTTPResponse(sock) - response.begin() - assert response.status == 200, response.status - assert json.loads(response.read())["host"] == "host-port.example.test" - checked += 1 + status, _, body = request(name, path=prefix + "fallback", http10=True) + assert status == 200, status + assert json.loads(body)["host"] == "host-port.example.test" + checked += 1 + + cache_cases = 0 + for prefix in ("/", "/custom/"): + path = prefix + f"cache-{uuid.uuid4().hex}.js" + cached = {} + authorities = [(False, "host-port.example.test:232"), (True, "host-port.example.test:233")] + for _ in range(2): + for tls, authority in authorities: + origin = f"{'https' if tls else 'http'}://{authority}" + status, headers, body = request(name, tls, path, + headers={"Host": authority, "Origin": origin, "Referer": origin + "/settings"}) + data = json.loads(body) + assert status == 200 and data["host"] == authority, ("cache", prefix, authority, status, data) + serial = headers.get("X-Upstream-Request") + assert serial and str(data["request_number"]) == serial, (headers, data) + if authority in cached: + # A dynamic upstream counter must not change on a real cache hit. + assert (serial, body) == cached[authority], ("cache hit", authority, cached, serial, body) + else: + cached[authority] = (serial, body) + cache_cases += 1 + assert len({serial for serial, _ in cached.values()}) == 2, cached + checked += cache_cases print(json.dumps({"image": image, "cases": checked, "nginx": "passed", "default_and_custom_locations": "passed", "http_and_https": "passed", - "origin_and_referer_validation": "passed", "missing_host_fallback": "passed"}), + "origin_and_referer_validation": "passed", "missing_host_fallback": "passed", "cached_asset_cases": cache_cases, + "cache_port_isolation_and_hits": "passed", "generated_host_readiness": "passed"}), flush=True) except Exception: subprocess.run(["docker", "logs", "--tail", "30", name], check=False)