Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/templates/_location.conf
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion docker/rootfs/etc/nginx/conf.d/include/proxy.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
add_header X-Served-By $host;
proxy_set_header Host $host;
proxy_set_header Host $forward_host;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cached files cross ports

With asset caching enabled, this include now forwards a port-specific Host, but assets.conf still uses proxy_cache_key $host$request_uri. Requests for the same JavaScript file at example.test:232 and example.test:233 therefore share a cache entry. If the upstream returns port-specific links, users on one port can receive links pointing to the other.

Include the forwarded authority in the asset cache key and add a two-port cache test.

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;
Expand Down
6 changes: 6 additions & 0 deletions docker/rootfs/etc/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions scripts/ci/test-and-build
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
12 changes: 12 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -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

```
Expand Down
167 changes: 167 additions & 0 deletions test/host-header-smoke.py
Original file line number Diff line number Diff line change
@@ -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"<script>window.top.location.href='/Main_Login.asp';</script>")
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)
Comment on lines +115 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Correct builds can fail

The test waits one second after signaling an Nginx reload, then rejects the first unexpected response. Reloading does not wait for the new workers to serve the generated host, so a busy CI machine can still return the old default page and fail a correct build.

Replace the fixed sleep with a bounded readiness check for the generated host and upstream before running the assertions.

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])