
OOB verifier for GHSA-c4j6-fc7j-m34r / CVE-2026-44578 (Next.js WebSocket-upgrade SSRF)
In-band verifier for GHSA-c4j6-fc7j-m34r / CVE-2026-44578 — Server-Side Request Forgery in Next.js via WebSocket upgrade requests.
⚠️ For authorized security testing only. You are responsible for ensuring you have permission to test every target you pass to this script.
| Field | Value |
|---|---|
| CVE | CVE-2026-44578 |
| GHSA | GHSA-c4j6-fc7j-m34r |
| CWE | CWE-918 (SSRF) |
| CVSS v3.1 | 8.6 (High) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N |
| Affected | next >=13.4.13 <15.5.16, >=16.0.0 <16.2.5 |
| Patched | 15.5.16, 16.2.5 |
| Fix commit | c4f69086 |
| Not affected | Vercel-hosted; output: "export"; deployments behind a reverse proxy that does not forward Upgrade |
An attacker opens a TCP connection to a self-hosted Next.js process and sends an HTTP/1.1 WebSocket upgrade whose request-URI is an absolute URL:
GET http://anything/<path> HTTP/1.1
Host: <target>
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
In resolveRoutes, the URL contains // (every absolute URI does), which
matches the "normalize repeated slashes" branch. That branch returns early
with { finished: true, statusCode: 308, parsedUrl: <mangled> }. The
normalizer collapses http://host/path into http:/host/path (one slash).
In router-server.ts, the pre-patch upgrade handler ignored
finished/statusCode and only checked parsedUrl.protocol. Since the
protocol survives normalization, it called proxyRequest(...).
proxyRequest runs url.format(parsedUrl) on the mangled URL, getting
http:/host:port/path. http-proxy parses that target, finds no host
(url.parse('http:/...').host === null), and falls back to its default
destination: localhost:80 (or localhost:443 for https).
So in practice the SSRF lets you make Next open a WebSocket upgrade to
the Next.js host's own localhost:80 / localhost:443 with an
attacker-controlled path.
The fix (commit c4f69086) made the upgrade handler check
finished && !statusCode before proxying. The 308-normalization case now
fails the !statusCode check and the socket is closed instead.
The proxy never reaches an external host. If you set up an interactsh /
Burp Collaborator / webhook canary and expect the Next process to phone home,
it will not — the connection goes to localhost on the target machine.
This verifier therefore uses an in-band signal read from the upgrade
socket: a vulnerable server returns a recognizable error body, a patched
server returns nothing.
The SSRF target is restricted but still meaningful in real deployments:
127.0.0.1:80 or :443 and trust localhost-originated
requests.127.0.0.1:80 (uncommon but seen).AWS / GCP / Azure metadata endpoints (169.254.169.254) are not directly
reachable because the bug pins the destination to localhost.
For each target, the script opens a raw TCP (or TLS) socket, sends the crafted upgrade, reads the response, and produces two signals:
verdict — whether the bug is present.impact_confirmed — whether the SSRF actually exfiltrated data
(i.e. a co-located service on localhost:80/443 of the target answered
and we got its response back).| Response | Verdict | impact_confirmed |
|---|---|---|
Contains Internal Server Error | vulnerable | false — bug proven, but proxy hit nothing on localhost |
Starts with HTTP/1. | vulnerable_proxy_succeeded | true — real response data exfiltrated |
| Empty / clean close | likely_patched | false — also covers "not Next", "reverse proxy stripped Upgrade", "Vercel" |
| Identical to no-Upgrade control | front_end_intercepts | false — front-end proxy short-circuited both probes; SSRF never reached Next |
| Anything else | inconclusive | false |
When impact_confirmed is true the JSON output also includes
upstream_status, upstream_server and upstream_content_type parsed
from the leaked response (useful for triage / report-writing).
By default, every target also receives a control probe with the same
absolute-URI request line but no Upgrade headers (Connection: close).
If the front-end returns the same response to both probes (status line +
size within tolerance), the host's own front-end is rejecting the
absolute-URI request line itself — nginx 400, Apache 400, CDN edge — and
the SSRF never reached Next. The verdict downgrades to
front_end_intercepts and the JSON output includes front_end_status
and front_end_server parsed from the proxy's response so the operator
can identify what is intercepting.
This eliminates a real-world false positive observed when self-hosted
Next sits behind nginx/Apache: those proxies reject the probe's
GET http:///x HTTP/1.1 request line with a generic 400, which the
detector previously misread as vulnerable_proxy_succeeded. Pass
--no-control-probe to opt out and see the raw verdicts.
# single target
python3 verify_ghsa_c4j6.py --target https://app.example.com
# multiple targets via flag repetition
python3 verify_ghsa_c4j6.py \
--target https://app1.example.com \
--target app2.example.com:3000 \
--target 10.0.0.5:80
# from a file (one target per line; '#' for comments)
python3 verify_ghsa_c4j6.py --targets-file targets.txt
# from stdin
cat targets.txt | python3 verify_ghsa_c4j6.py
# JSON Lines output for downstream tooling
python3 verify_ghsa_c4j6.py --targets-file targets.txt --json
# Enumerate co-located services on the target's localhost:80/443 via the bug
python3 verify_ghsa_c4j6.py --target https://app.example.com --scan
# Same, with a custom path list
python3 verify_ghsa_c4j6.py --target ... --scan-paths-file my_paths.txt
--scan probes a built-in list of common paths (Apache/nginx status modules,
health & metrics endpoints, Spring Boot Actuator, Go pprof, Docker daemon
endpoints, common admin panels, leaky config files, Elasticsearch routes,
etc.) through the SSRF gadget.
By default, scan mode runs one extra differential baseline probe with
a random non-existent path per target. Subsequent probes are tagged
DIFF only when their (status, body length) signature diverges from
the baseline — uniform 404s from a "found nothing" upstream are marked
noise and don't inflate the hit count. Pass --no-differential to
report every probe that reached a service (legacy behavior).
Output is grouped per target: