"""Exercise the shipped Nginx template with a local header-echo upstream. Run: python3 test/proxy_transport_test.py /path/to/nginx No production data, credentials, TLS assumption or Rails runtime required. """ import http.server import json import os import pwd from pathlib import Path import socket import subprocess import sys import tempfile import threading import time import urllib.request class Echo(http.server.BaseHTTPRequestHandler): def do_GET(self): body = json.dumps(dict(self.headers)).encode() self.send_response(200) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass def port(): with socket.socket() as sock: sock.bind(('127.0.0.1', 0)) return sock.getsockname()[1] def check(nginx, trusted_cidr, upstream, cases): template = (Path(__file__).resolve().parents[1] / 'frontend/nginx.conf').read_text() listen = port() with tempfile.TemporaryDirectory(prefix='pcm-proxy-test-') as directory: config = Path(directory) / 'nginx.conf' rendered = template.replace('${TRUSTED_TLS_PROXY_CIDR}', trusted_cidr) rendered = rendered.replace('listen 80;', f'listen 127.0.0.1:{listen};') rendered = rendered.replace('http://api:3000', f'http://127.0.0.1:{upstream}') current_user = pwd.getpwuid(os.geteuid()).pw_name config.write_text(f'user {current_user}; master_process off; daemon off; pid {directory}/nginx.pid;\n' f'error_log {directory}/error.log; events {{}}\nhttp {{ access_log off; ' f'client_body_temp_path {directory}/body; proxy_temp_path {directory}/proxy; ' f'fastcgi_temp_path {directory}/fastcgi; uwsgi_temp_path {directory}/uwsgi; ' f'scgi_temp_path {directory}/scgi; {rendered} }}') validation = subprocess.run([nginx, '-t', '-c', str(config), '-p', directory], capture_output=True, text=True) if validation.returncode: raise RuntimeError(validation.stderr) process = subprocess.Popen([nginx, '-c', str(config), '-p', directory], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) try: for attempt in range(100): try: with socket.create_connection(('127.0.0.1', listen), timeout=.1): break except OSError: if process.poll() is not None: raise RuntimeError(process.stderr.read().decode()) time.sleep(.02) for headers, expected in cases: for path in ['/api/v1/practice_transfer', '/admin/pghero']: request = urllib.request.Request(f'http://127.0.0.1:{listen}{path}', headers=headers) with urllib.request.urlopen(request, timeout=3) as response: forwarded = json.load(response) assert forwarded['X-Forwarded-Proto'] == expected, forwarded assert 'Forwarded' not in forwarded, forwarded assert 'X-Forwarded-Ssl' not in forwarded, forwarded assert 'X-Forwarded-Scheme' not in forwarded, forwarded finally: process.terminate() process.communicate(timeout=5) if __name__ == '__main__': nginx = str(Path(sys.argv[1]).resolve()) if len(sys.argv) > 1 else 'nginx' upstream = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Echo) thread = threading.Thread(target=upstream.serve_forever, daemon=True) thread.start() try: check(nginx, '127.0.0.1/32', upstream.server_port, [ ({'X-Forwarded-Proto': 'https'}, 'https'), ({'X-Forwarded-Proto': 'http'}, 'http'), ({}, 'http'), ({'X-Forwarded-Proto': 'https,http'}, 'http'), ({'Forwarded': 'proto=https', 'X-Forwarded-Ssl': 'on', 'X-Forwarded-Scheme': 'https'}, 'http'), ]) check(nginx, '192.0.2.1/32', upstream.server_port, [ ({'X-Forwarded-Proto': 'https', 'Forwarded': 'proto=https', 'X-Forwarded-Ssl': 'on'}, 'http'), ({}, 'http'), ]) print('PASS: 14 proxy transport cases; trusted HTTPS retained, untrusted assertions rejected.') finally: upstream.shutdown() upstream.server_close()