#!/usr/bin/env python3 """Bounded, synthetic nginx topology; uses an installed image, never pulls/deploys.""" import argparse import json from pathlib import Path import subprocess import tempfile import urllib.error import urllib.request import uuid ROOT = Path(__file__).resolve().parents[1] def docker(*args): result = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=30) if result.returncode: raise RuntimeError(result.stderr.strip()) return result.stdout.strip() def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--image", default="nginx:1.29.8-alpine", help="Already installed nginx image with realip and wget") parser.add_argument("--baseline", action="store_true", help="Use HEAD's pre-B06 nginx sources and demonstrate the old bypass") args = parser.parse_args() docker("info", "--format", "{{.ServerVersion}}") docker("image", "inspect", args.image) # Fail before resource creation if unavailable. prefix = "b06-" + uuid.uuid4().hex[:10] containers, networks = [], [] artifact_root = ROOT / ".artifacts" / "ingress-topology" artifact_root.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=prefix, dir=artifact_root) as directory: directory = Path(directory).resolve() assert directory.is_relative_to(artifact_root.resolve()) def create(name, *options): name = prefix + "-" + name docker("create", "--pull=never", "--name", name, *options) containers.append(name) return name try: edge = prefix + "-edge" private = prefix + "-private" docker("network", "create", edge) networks.append(edge) docker("network", "create", "--internal", private) networks.append(private) peer = create("peer", "--network", edge, "--entrypoint", "sleep", args.image, "300") docker("start", peer) peer_state = json.loads(docker("inspect", peer))[0] peer_ip = peer_state["NetworkSettings"]["Networks"][edge]["IPAddress"] for source, target in [("nginx.conf.template", "template"), ("configure-nginx-origin.sh", "configure.sh")]: content = (subprocess.check_output(["git", "show", f"HEAD:job-tracker-ui/{source}"], cwd=ROOT).decode() if args.baseline else (ROOT / "job-tracker-ui" / source).read_text()) (directory / target).write_text(content, newline="\n") (directory / "backend.conf").write_text('''server { listen 8080; location / { default_type application/json; return 200 '{"ip":"$http_x_forwarded_for","scheme":"$http_x_forwarded_proto","host":"$http_host","forwardedHost":"$http_x_forwarded_host","forwarded":"$http_forwarded","session":"$http_x_session_context"}'; } } ''', newline="\n") backend = create("backend", "--network", private, "--network-alias", "backend-web", "-v", f"{directory / 'backend.conf'}:/etc/nginx/conf.d/default.conf:ro", args.image) docker("start", backend) frontend = create("frontend", "--network", edge, "--network-alias", "frontend", "-p", "127.0.0.1::80", "-e", "APP_PUBLIC_BASE_URL=https://jobs.example.test", "-e", "INGRESS_MODE=proxy", "-e", f"INGRESS_PROXY_CIDRS={peer_ip}/32", "-v", f"{directory / 'template'}:/etc/nginx/jobtracker.conf.template:ro", "-v", f"{directory / 'configure.sh'}:/docker-entrypoint.d/15-jobtracker-origin.sh:ro", # Invoke through sh so mounted Windows execute bits do not determine the result. "--entrypoint", "sh", args.image, "-c", "sh /docker-entrypoint.d/15-jobtracker-origin.sh && exec nginx -g 'daemon off;'") docker("network", "connect", private, frontend) docker("start", frontend) docker("exec", frontend, "sh", "-c", "for i in 1 2 3 4 5; do wget -q -O /dev/null http://127.0.0.1/health && exit 0; sleep 1; done; exit 1") headers = {"Host": "jobs.example.test", "X-Forwarded-For": "198.51.100.10", "X-Forwarded-Proto": "https", "X-Forwarded-Host": "attacker.example.test", "Forwarded": "host=attacker.example.test", "X-Session-Context": "synthetic-session"} def proxied(extra=None): values = {**headers, **(extra or {})} command = ["docker", "exec", peer, "wget", "-S", "-O", "-"] for name, value in values.items(): command.append(f"--header={name}: {value}") command.append("http://frontend/api/probe") return subprocess.run(command, capture_output=True, text=True, timeout=10) binding = docker("port", frontend, "80/tcp") opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) request = urllib.request.Request(f"http://{binding}/api/probe", headers=headers) try: with opener.open(request, timeout=10) as response: direct_status, direct_body = response.status, response.read().decode() except urllib.error.HTTPError as error: direct_status, direct_body = error.code, error.read().decode() if args.baseline: assert direct_status == 200 and json.loads(direct_body)["ip"] == "198.51.100.10" print(json.dumps({"baselineDirect": direct_status, "forgedIpAccepted": True})) return assert direct_status == 403, (direct_status, direct_body) successful = [] for client in ["198.51.100.10", "198.51.100.11", "2001:db8::10"]: result = proxied({"X-Forwarded-For": client}) assert result.returncode == 0, result.stderr body = json.loads(result.stdout) assert body == {"ip": client, "scheme": "https", "host": "jobs.example.test", "forwardedHost": "", "forwarded": "", "session": "synthetic-session"}, body successful.append(client) for extra in [{"X-Forwarded-For": "203.0.113.9, 198.51.100.10"}, {"X-Forwarded-Proto": "http"}, {"X-Forwarded-Proto": "https,http"}]: result = proxied(extra) assert result.returncode != 0 and "400" in result.stderr, result.stderr malformed = json.loads(proxied({"X-Forwarded-For": "invalid-address"}).stdout) assert malformed["ip"] == peer_ip # realip fails closed to the real peer, not forged data. backend_state = json.loads(docker("inspect", backend))[0] assert not any(backend_state["NetworkSettings"]["Ports"].values()) backend_ip = backend_state["NetworkSettings"]["Networks"][private]["IPAddress"] # A peer attached only to the edge network cannot route directly to the internal backend. denied = subprocess.run(["docker", "exec", peer, "wget", "-T", "2", "-qO-", f"http://{backend_ip}:8080/api/probe"], capture_output=True, timeout=5) assert denied.returncode != 0 print(json.dumps({"directNginx": direct_status, "trustedClientIps": successful, "chainsAndBadScheme": 400, "malformedIp": "socket-peer fallback", "directBackend": "unreachable", "backendPublishedPorts": 0})) finally: for name in reversed(containers): subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30) for name in reversed(networks): subprocess.run(["docker", "network", "rm", name], capture_output=True, timeout=30) if __name__ == "__main__": main()