mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-10 12:28:28 +00:00
Merge pull request #5934 from lennondotw/fix/preserve-host-port
fix(proxy): preserve client ports in forwarded Host headers
This commit is contained in:
7 files changed
+275
-3
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
@@ -1,5 +1,17 @@
|
||||
# Cypress Test Suite
|
||||
|
||||
## Host Header Regression Test
|
||||
|
||||
Run `python3 test/host-header-smoke.py <built-image>` 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
|
||||
|
||||
```
|
||||
@@ -44,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.
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check forwarded Host headers against a real upstream in a disposable image."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
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):
|
||||
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")
|
||||
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"<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()
|
||||
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
|
||||
|
||||
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: {id: 999, provider: "other"}, access_list_id: 0,
|
||||
ssl_forced: false, hsts_enabled: false, http2_support: 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}]
|
||||
};
|
||||
await nginx.generateConfig("proxy_host", host);
|
||||
process.exit(0);
|
||||
'''
|
||||
|
||||
|
||||
def docker(*args, **kwargs):
|
||||
return subprocess.check_output(["docker", *args], text=True, **kwargs).strip()
|
||||
|
||||
|
||||
# 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(args["method"], args["path"], headers=args["headers"], body=args["body"])
|
||||
response = connection.getresponse()
|
||||
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):
|
||||
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",
|
||||
"--mount", "type=volume,destination=/etc/letsencrypt", image)
|
||||
deadline = time.monotonic() + 120
|
||||
while True:
|
||||
try:
|
||||
status, _, body = request(name, path="/api/", admin=True)
|
||||
if status == 200 and json.loads(body).get("status") == "OK":
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
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")
|
||||
wait_for_generated_host(name)
|
||||
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, headers, body = request(
|
||||
name, tls, path, method,
|
||||
{"Host": authority, "Origin": origin, "Referer": referer}, payload)
|
||||
except OSError as error:
|
||||
raise AssertionError((authority, path, method, str(error))) from error
|
||||
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,
|
||||
"body": payload or ""}, body
|
||||
checked += 1
|
||||
# HTTP/1.0 permits no Host. An absolute target selects our generated host.
|
||||
for prefix in ("/", "/custom/"):
|
||||
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", "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)
|
||||
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])
|
||||
Reference in new issue
Block a user