CVE-2026-44578 is a Next.js server-side request forgery via WebSocket upgrade requests, fixed in 16.2.5 and 15.5.16 on May 11, 2026. The GitHub advisory says the bug exposes "arbitrary internal or external destinations" and names cloud metadata endpoints. The CVSS is 8.6 with AV:N/AC:L/PR:N/UI:N/S:C, the scope-changing rating that says the impact crosses a security boundary. Three of the four public PoCs ship "exploit" code that issues GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ against a vulnerable Next.js host and claims to dump AWS access keys. The fourth PoC, an in-band verifier whose author actually ran the absolute-URL exploit against [email protected], says the bug cannot reach 169.254.169.254. The destination is localhost on the Next.js host itself, because the same URL normalization that triggers the bug strips the host part before the proxy parses it.
CVE-2026-44578: The Advisory Names Cloud Metadata. The Bug Pins to Localhost.
patterns
cve
proof of concept
- advisories/GHSA-c4j6-fc7j-m34r
- tocong282/CVE-2026-44578-PoC%20(%E2%98%850)
- love07oj/nextjs-cve-2026-44578%20(%E2%98%850)
- projectdiscovery/nuclei-templates
- dinosn/CVE-2026-44578%20(%E2%98%850)
- 0xBlackash/CVE-2026-44578%20(%E2%98%850)
- GadaLuBau1337/CVE-2026-44578%20(%E2%98%850)
- lxxexxbxx/CVE-2026-44578%20(%E2%98%850)
- isaca0315/CVE-2026-44578-next-js-ssrf%20(%E2%98%850)
The upgrade handler read one field of a four-field decision
The pre-patch upgrade handler in packages/next/src/server/lib/router-server.ts at line 898, on the v16.2.4 tag, looks like this:
const { matchedOutput, parsedUrl } = await resolveRoutes({
req,
res,
isUpgradeReq: true,
signal: signalFromNodeResponse(socket),
})
// TODO: allow upgrade requests to pages/app paths?
// this was not previously supported
if (matchedOutput) {
return socket.end()
}
if (parsedUrl.protocol) {
return await proxyRequest(req, socket, parsedUrl, head)
}resolveRoutes is the routing pipeline. For every request, upgrade or not, it returns the same record. Look at the function's declared return type in packages/next/src/server/lib/router-utils/resolve-routes.ts at line 128:
Promise<{
finished: boolean
statusCode?: number
bodyStream?: ReadableStream | null
resHeaders: Record<string, string | string[]> | null
parsedUrl: NextUrlWithParsedQuery
matchedOutput?: FsOutput | null
}>Six fields. The non-upgrade pipeline (line 424) destructures five of them and honors them. The upgrade handler destructures two: matchedOutput and parsedUrl. It does not read finished. It does not read statusCode. It reads parsedUrl.protocol and treats its truthiness as authorization to call proxyRequest.
The patch by JJ Kasper (commit 5b194ee2d4, April 21, 2026) is one block:
- const { matchedOutput, parsedUrl } = await resolveRoutes({
+ const { finished, matchedOutput, parsedUrl, statusCode } =
+ await resolveRoutes({
req,
res,
isUpgradeReq: true,
signal: signalFromNodeResponse(socket),
})
if (matchedOutput?.fsPath) {
return socket.end()
}
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+
+ return socket.end()
}The destructure on line 1 adds two field names. The action on line 12 reads them. The same fields were always being returned. The pre-patch handler asked for two of the six. The other four included the gate's verdict on whether the request was finished, and what status code it had set.
The normalization that fires the bug strips the destination
The reason an attacker-controlled absolute URL ends up reaching parsedUrl.protocol === 'http:' is the very first conditional inside resolveRoutes. The code that fires is at line 156:
// this normalizes repeated slashes in the path e.g. hello//world ->
// hello/world or backslashes to forward slashes, this does not
// handle trailing slash as that is handled the same as a next.config.js
// redirect
if (urlNoQuery?.match(/(\\|\/\/)/)) {
parsedUrl = parseUrl(normalizeRepeatedSlashes(req.url!))
return {
parsedUrl,
resHeaders,
finished: true,
statusCode: 308,
}
}normalizeRepeatedSlashes is the function imported from packages/next/src/shared/lib/utils.ts:
export function normalizeRepeatedSlashes(url: string) {
const urlParts = url.split('?')
const urlNoQuery = urlParts[0]
return (
urlNoQuery
.replace(/\\/g, '/')
.replace(/\/\/+/g, '/') +
(urlParts[1] ? `?${urlParts.slice(1).join('?')}` : '')
)
}The regex /\/\/+/g collapses two or more consecutive forward slashes to one. It is named, documented, and intended to canonicalize paths like /foo//bar into /foo/bar and 308-redirect to the canonical form. It is a routing-hygiene control.
Every absolute-form request URI contains //. When an attacker writes GET http://internal-target:6379/path HTTP/1.1 to the Next.js socket, Node.js sets req.url to the full http://internal-target:6379/path, not a relative /path. The conditional at line 156 matches because // is in the URL. The normalizer fires. The // in http:// collapses to /. The returned record is:
{
finished: true,
statusCode: 308,
parsedUrl: parseUrl('http:/internal-target:6379/path'),
matchedOutput: null,
resHeaders: {}
}parseUrl is the legacy Node.js url.parse. On a URL with one slash after the protocol, it produces:
> url.parse('http:/internal-target:6379/path').protocol
'http:'
> url.parse('http:/internal-target:6379/path').slashes
false
> url.parse('http:/internal-target:6379/path').host
null
> url.parse('http:/internal-target:6379/path').pathname
'/internal-target:6379/path'The authority section of a URL, //user:pass@host:port, requires the double slash to exist. After normalization, the URL has one slash. There is no authority. The host is null. The attacker-supplied hostname has slid into the path component. The protocol survived.
The pre-patch upgrade handler reads parsedUrl.protocol, finds 'http:', and calls proxyRequest. proxyRequest runs url.format(parsedUrl) to rebuild the target string, getting back http:/internal-target:6379/path. That string is handed to the bundled http-proxy library as the proxy target. The library's URL parser sees no host. Its setupOutgoing helper falls back to its default destination: localhost, port 80 for http:, port 443 for https:. The Next.js process opens a TCP socket to 127.0.0.1:80 on its own machine. The original internal-target:6379 is in the pathname. It is irrelevant to where the connection went.
The bug exists because the upgrade handler did not read finished. The destination is localhost because the same normalization that flipped finished to true also collapsed // out of the authority slot. The attacker controls everything about the request except the one thing they were trying to control: which host the proxy connects to.
One PoC ran the patch. Three inferred it.
The most starred public PoC, ynsmroztas/nextssrf, is offensive tooling with banner art, ANSI colors, an "interactive exploit shell," and demo screenshots of AWS credentials being exfiltrated. The shell's aws command claims to walk /latest/meta-data/iam/security-credentials/<role> and dump the access key, secret key, and expiration. The README's Limitations section is honest about IMDSv2 being unreachable (the metadata service requires PUT, and the bug is GET-only) and about GCP rejecting Upgrade: websocket outright. The Limitations list does not include "the SSRF cannot reach 169.254.169.254 at all." The screenshots imply it can.
The second PoC, dwisiswant0/next-16.2.4-pocs, is a release-window collection covering all twelve advisories from the v16.2.4..v16.2.5 window. The CVE-2026-44578 entry was, by the author's own admission, "reverse-engineered with Neo by ProjectDiscovery," an AI tool that infers exploit shape from the patch diff. Its docstring says: "we coerce the Next.js process to open a TCP connection to ANY internal target (e.g. AWS IMDS 169.254.169.254, Redis on 127.0.0.1:6379, k8s API, ...)." The script ships two variants. Variant 1 is the absolute-URL form, which the normalizer mangles. Variant 2 is "relative URL + spoofed Host header," which the docstring acknowledges only "works on apps that use headers() rewrites."
The third PoC, tocong282/CVE-2026-44578-PoC, is a sixty-line Python script whose exploit function does websocket.create_connection(ws_url, header={"Host": "127.0.0.1:6379"}). It assumes the Host header reaches the proxy target. It tests nothing.
The fourth PoC, panchocosil/verify-ghsa-c4j6-fc7j-m34r, is structured differently. The README spends a full section on the heading "Why a callback-based OOB verifier won't work for this CVE." The author set up an interactsh canary, expected the Next process to phone home, and got nothing. They cloned [email protected] and [email protected], ran the absolute-URL exploit against both, and traced where the proxy actually went. The connection went to localhost on the target machine. They rewrote their detection model around in-band signals: a vulnerable server returns a recognizable proxy-library error body; a patched server returns nothing.
The Next.js team agrees with panchocosil. The regression test added in the patch commit (test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts) names its SSRF probe target 127.0.0.1:${backendPort} and its probe body 'SSRF_CONFIRMED: You reached the internal service at 127.0.0.1'. The Next.js team did not name 169.254.169.254 anywhere in the test. They named localhost. They proved the bug reaches localhost.
Three PoC authors inferred the primitive from the patch. One PoC author ran the patch. The Next.js team wrote the regression test for what the patch fixes, and what the patch fixes is reaching localhost.
fail-open-intercept, with the gate's verdict in a field the action never read
The pattern catalog names this shape Fail Open Intercept: a gate decides, and the inner action the decision was supposed to govern proceeds anyway. The canonical exhibit is Tomcat's EncryptInterceptor, where the gate catches a decryption failure inside a try block, logs it, and then calls super.messageReceived(msg) outside the try. The bytes the gate could not decrypt reach the deserializer. The gate ran. The gate identified the failure. The action proceeded.
Next.js's variant is fail-open-intercept transposed from exception flow to return-value flow. resolveRoutes did not throw. It set finished: true and statusCode: 308 in the record it returned. The verdict was in the record. The caller did not destructure the verdict fields, so the listener was wired to a channel the verdict did not enter.
The pattern recurs in this codebase. The same release window (v16.2.4..v16.2.5) closes twelve advisories, three of which are routing-decision-vs-action mismatches. CVE-2026-44574 is "Dynamic-route and middleware mismatch." CVE-2026-44575 is "App Router prefetch middleware bypass." CVE-2026-44578 is the upgrade handler's variant. router-server.ts is a multi-consumer interface to a single routing decision, and each consumer reads the slice it cares about. The patches close one consumer at a time. The codebase still ships a routing pipeline whose decision is a six-field record consumed by callers free to read any subset.
Vercel-hosted is not affected because the upgrade handler is not the upgrade handler there
The advisory specifies: "Vercel-hosted deployments are not affected." Vercel does not deploy Next.js as a long-lived next start process exposing TCP. Vercel routes incoming requests through their edge infrastructure, which terminates HTTP and dispatches into serverless functions or edge functions. The server.on('upgrade', ...) handler in router-server.ts does not run in that hosting model. The bug requires the standalone Node.js server.
This is the catalog's parallel-implementation-gap shape: the same client-facing contract is implemented by parallel modules across deployment targets, and only one of them was hardened. Self-hosting is a supported configuration. The patch is for the self-hosted path. Vercel customers were never on the codepath that ships with the bug. Self-hosters are the population this CVE describes, and they are the population the advisory's CVSS 8.6 reaches.
The reachable surface is localhost, which is not nothing
The SSRF target, in the universal absolute-URL variant of CVE-2026-44578, is the Next.js host's localhost:80 or localhost:443. This is narrower than the advisory's framing of "internal or external destinations" suggests, and it is not nothing. In modern self-hosted Next.js deployments, services that bind localhost include:
- Sidecar containers in the same pod that listen on
127.0.0.1and trust localhost-originated requests. Admin panels, log shippers, service-mesh control planes, internal metrics endpoints. - Docker daemon over HTTP on
127.0.0.1:80in development containers. - Co-located HTTP services with attacker-controlled URI path. The empirical verifier's
--scanmode walks/server-status,/.env,/actuator/env,/debug/pprof/,/containers/json, and returns the response bodies back through the upgrade socket.
The variant that can reach addresses other than localhost is the Host-header form, which depends on the application using headers() rewrites that promote the Host header into the proxy target. Not every app does. Most do not. The empirical evidence on whether arbitrary cloud metadata endpoints are reachable through any production Next.js configuration remains weak; the AWS-credential screenshots in the popular PoC are demonstrations against a custom lab harness, not against 169.254.169.254 itself.
Defenders reading the advisory and budgeting for "AWS metadata exfil from internet-reachable Next.js" are budgeting for an attack the bug's most universal form cannot deliver. The attack the universal form can deliver is: anyone who can reach the Next.js port from the internet can read whatever is bound to 127.0.0.1 on the Next.js host, with an attacker-controlled path. That changes which sidecars get audited, which container network policies get tightened, and which IMDSv2 enforcements stop mattering as compensating controls for this CVE.
The CVSS vector says S:C. The mechanism says scope unchanged: localhost on the same host. The most-starred PoC shows screenshots of AWS credential extraction. The verifier whose author actually ran the exploit says the proxy never leaves the host.
The advisory names cloud metadata. The bug stops at localhost.