fix(proxy): isolate cached assets by forwarded authority

This commit is contained in:
lennondotw committed 2026-10-14 14:48:22 +08:00
1 parent c50c06530b
commit 3a455595e7
3 files changed
+119 -34

No files matched your search

@@ -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;
+11
View File
@@ -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 <built-image>` 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.
+107 -33
View File
@@ -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"<script>window.top.location.href='/Main_Login.asp';</script>")
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)