mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-15 17:18:04 +00:00
fix(proxy): isolate cached assets by forwarded authority
This commit is contained in:
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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user