diff --git a/docker/rootfs/etc/nginx/conf.d/include/assets.conf b/docker/rootfs/etc/nginx/conf.d/include/assets.conf index 5a90beb8a..9157d172d 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 57b9eb36a..20be040e7 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 a823b8977..a1397d878 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 @@ import http.server, json, ssl 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 @@ class Backend(http.server.BaseHTTPRequestHandler): 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 @@ 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, + 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)