Created
March 15, 2026 16:03
-
-
Save devnexen/390ea4f2da6678a3a2e024333ad5c36e to your computer and use it in GitHub Desktop.
PoC: nginx early_hints_length not reset on upstream reinit (PR #1187)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| PoC: early_hints_length not reset on upstream reinit | |
| Demonstrates that when nginx retries a request to a second upstream | |
| after receiving 103 Early Hints from the first, the accumulated | |
| early_hints_length carries over, causing the second upstream's | |
| early hints to be incorrectly rejected as "too big". | |
| Bug location: src/http/ngx_http_upstream.c | |
| - ngx_http_upstream_reinit() does not reset u->early_hints_length | |
| - ngx_http_upstream_process_early_hints() accumulates into it | |
| Scenario: | |
| 1. Client sends GET to nginx | |
| 2. nginx forwards to backend 1 | |
| 3. Backend 1 sends 103 Early Hints (~300 bytes), then 502 | |
| 4. nginx retries to backend 2 (proxy_next_upstream http_502) | |
| 5. Backend 2 sends 103 Early Hints (~300 bytes), then 200 OK | |
| 6. BUG: nginx rejects backend 2's hints as "too big" because | |
| early_hints_length still has ~300 from backend 1, and | |
| 300 + 300 = 600 > proxy_buffer_size (512) | |
| Fix: add u->early_hints_length = 0 in ngx_http_upstream_reinit() | |
| Usage: | |
| python3 poc_early_hints.py [path/to/nginx] | |
| """ | |
| import atexit | |
| import os | |
| import shutil | |
| import socket | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| BACKEND1_PORT = 18081 | |
| BACKEND2_PORT = 18082 | |
| NGINX_PORT = 18080 | |
| # ~300 bytes of Link headers per 103 response. | |
| # Two of these exceed proxy_buffer_size of 512. | |
| EARLY_HINTS_HEADERS = ( | |
| b"Link: </assets/css/main-stylesheet-with-a-very-long-name.css>;" | |
| b" rel=preload; as=style; crossorigin=anonymous\r\n" | |
| b"Link: </assets/js/application-bundle-with-a-very-long-name.js>;" | |
| b" rel=preload; as=script; crossorigin=anonymous\r\n" | |
| b"Link: </assets/fonts/custom-webfont-with-a-very-long-name.woff2>;" | |
| b" rel=preload; as=font; crossorigin=anonymous\r\n" | |
| ) | |
| def backend_handler(port, status_after_hints, body=b"OK"): | |
| """HTTP/1.1 backend that sends 103 Early Hints then a final response.""" | |
| srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| srv.bind(("127.0.0.1", port)) | |
| srv.listen(5) | |
| srv.settimeout(10) | |
| try: | |
| conn, _ = srv.accept() | |
| conn.settimeout(5) | |
| conn.recv(4096) | |
| # 103 Early Hints | |
| conn.sendall( | |
| b"HTTP/1.1 103 Early Hints\r\n" + EARLY_HINTS_HEADERS + b"\r\n" | |
| ) | |
| # Final response | |
| conn.sendall( | |
| f"HTTP/1.1 {status_after_hints}\r\n" | |
| f"Content-Length: {len(body)}\r\n" | |
| f"Connection: close\r\n" | |
| f"\r\n".encode() + body | |
| ) | |
| conn.close() | |
| except socket.timeout: | |
| pass | |
| finally: | |
| srv.close() | |
| def setup_nginx(nginx_bin): | |
| """Create temp dir with nginx config and required subdirs.""" | |
| tmpdir = tempfile.mkdtemp(prefix="nginx_poc_") | |
| for subdir in ("logs", "client_body_temp", "proxy_temp"): | |
| os.makedirs(os.path.join(tmpdir, subdir), exist_ok=True) | |
| conf = f"""\ | |
| daemon off; | |
| master_process off; | |
| worker_processes 1; | |
| error_log logs/error.log info; | |
| pid logs/nginx.pid; | |
| events {{ | |
| worker_connections 64; | |
| }} | |
| http {{ | |
| access_log off; | |
| client_body_temp_path client_body_temp; | |
| proxy_temp_path proxy_temp; | |
| upstream backends {{ | |
| server 127.0.0.1:{BACKEND1_PORT}; | |
| server 127.0.0.1:{BACKEND2_PORT}; | |
| }} | |
| server {{ | |
| listen {NGINX_PORT}; | |
| location / {{ | |
| proxy_pass http://backends; | |
| proxy_next_upstream error timeout http_502; | |
| proxy_buffer_size 512; | |
| proxy_connect_timeout 2s; | |
| }} | |
| }} | |
| }} | |
| """ | |
| with open(os.path.join(tmpdir, "nginx.conf"), "w") as f: | |
| f.write(conf) | |
| return tmpdir | |
| def main(): | |
| if len(sys.argv) > 1: | |
| nginx_bin = sys.argv[1] | |
| else: | |
| nginx_bin = os.path.join( | |
| os.path.dirname(os.path.abspath(__file__)), "objs", "nginx" | |
| ) | |
| if not os.path.exists(nginx_bin): | |
| print(f"ERROR: nginx binary not found at {nginx_bin}") | |
| sys.exit(1) | |
| tmpdir = setup_nginx(nginx_bin) | |
| error_log = os.path.join(tmpdir, "logs", "error.log") | |
| print(f"[*] Temp dir: {tmpdir}") | |
| print(f"[*] nginx bin: {nginx_bin}") | |
| # --- Start backends --- | |
| print(f"[*] Starting backend 1 (:{BACKEND1_PORT}, sends 103 then 502)...") | |
| print(f"[*] Starting backend 2 (:{BACKEND2_PORT}, sends 103 then 200)...") | |
| t1 = threading.Thread( | |
| target=backend_handler, | |
| args=(BACKEND1_PORT, "502 Bad Gateway", b"fail"), | |
| ) | |
| t2 = threading.Thread( | |
| target=backend_handler, | |
| args=(BACKEND2_PORT, "200 OK", b"OK"), | |
| ) | |
| t1.daemon = True | |
| t2.daemon = True | |
| t1.start() | |
| t2.start() | |
| time.sleep(0.3) | |
| # --- Start nginx --- | |
| print(f"[*] Starting nginx (:{NGINX_PORT})...") | |
| nginx_proc = subprocess.Popen( | |
| [nginx_bin, "-p", tmpdir, "-c", "nginx.conf"], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| ) | |
| atexit.register(lambda: nginx_proc.terminate()) | |
| time.sleep(0.5) | |
| if nginx_proc.poll() is not None: | |
| print(f"ERROR: nginx exited early: {nginx_proc.stderr.read().decode()}") | |
| sys.exit(1) | |
| # --- Send request --- | |
| print(f"[*] Sending GET / to nginx...") | |
| try: | |
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| sock.settimeout(5) | |
| sock.connect(("127.0.0.1", NGINX_PORT)) | |
| sock.sendall(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") | |
| response = b"" | |
| while True: | |
| try: | |
| data = sock.recv(4096) | |
| if not data: | |
| break | |
| response += data | |
| except socket.timeout: | |
| break | |
| sock.close() | |
| except Exception as e: | |
| print(f"ERROR: request failed: {e}") | |
| nginx_proc.terminate() | |
| sys.exit(1) | |
| # --- Collect results --- | |
| t1.join(timeout=3) | |
| t2.join(timeout=3) | |
| nginx_proc.terminate() | |
| nginx_proc.wait(timeout=3) | |
| print(f"\n--- Response ---") | |
| print(response.decode(errors="replace")) | |
| # --- Check for bug --- | |
| with open(error_log, "r") as f: | |
| log_content = f.read() | |
| bug_triggered = "upstream sent too big early hints" in log_content | |
| print(f"--- Result ---") | |
| if bug_triggered: | |
| print("BUG REPRODUCED: 'upstream sent too big early hints'") | |
| print() | |
| print(" early_hints_length was NOT reset on upstream retry.") | |
| print(" Backend 2's valid early hints were rejected because the") | |
| print(" accumulated length included backend 1's hints from the") | |
| print(" failed attempt.") | |
| print() | |
| for line in log_content.splitlines(): | |
| if "early hints" in line.lower(): | |
| print(f" LOG: {line.strip()}") | |
| else: | |
| print("Bug NOT reproduced.") | |
| print(f" Check: {error_log}") | |
| # Cleanup | |
| shutil.rmtree(tmpdir, ignore_errors=True) | |
| sys.exit(0 if bug_triggered else 1) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment