Skip to content

Instantly share code, notes, and snippets.

@devnexen
Created March 15, 2026 16:03
Show Gist options
  • Select an option

  • Save devnexen/390ea4f2da6678a3a2e024333ad5c36e to your computer and use it in GitHub Desktop.

Select an option

Save devnexen/390ea4f2da6678a3a2e024333ad5c36e to your computer and use it in GitHub Desktop.
PoC: nginx early_hints_length not reset on upstream reinit (PR #1187)
#!/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