-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The upgrade handler read one field of a four-field decision\n\nThe 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:\n\n```ts\nconst { matchedOutput, parsedUrl } = await resolveRoutes({\n req,\n res,\n isUpgradeReq: true,\n signal: signalFromNodeResponse(socket),\n})\n\n// TODO: allow upgrade requests to pages/app paths?\n// this was not previously supported\nif (matchedOutput) {\n return socket.end()\n}\n\nif (parsedUrl.protocol) {\n return await proxyRequest(req, socket, parsedUrl, head)\n}\n```\n\n`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:\n\n```ts\nPromise<{\n finished: boolean\n statusCode?: number\n bodyStream?: ReadableStream | null\n resHeaders: Record | null\n parsedUrl: NextUrlWithParsedQuery\n matchedOutput?: FsOutput | null\n}>\n```\n\nSix 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`.\n\nThe patch by JJ Kasper (commit `5b194ee2d4`, April 21, 2026) is one block:\n\n```diff\n- const { matchedOutput, parsedUrl } = await resolveRoutes({\n+ const { finished, matchedOutput, parsedUrl, statusCode } =\n+ await resolveRoutes({\n req,\n res,\n isUpgradeReq: true,\n signal: signalFromNodeResponse(socket),\n })\n\n if (matchedOutput?.fsPath) {\n return socket.end()\n }\n\n- if (parsedUrl.protocol) {\n- return await proxyRequest(req, socket, parsedUrl, head)\n+ if (finished && parsedUrl.protocol) {\n+ if (!statusCode) {\n+ return await proxyRequest(req, socket, parsedUrl, head)\n+ }\n+\n+ return socket.end()\n }\n```\n\nThe 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.\n\n## The normalization that fires the bug strips the destination\n\nThe 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:\n\n```ts\n// this normalizes repeated slashes in the path e.g. hello//world ->\n// hello/world or backslashes to forward slashes, this does not\n// handle trailing slash as that is handled the same as a next.config.js\n// redirect\nif (urlNoQuery?.match(/(\\\\|\\/\\/)/)) {\n parsedUrl = parseUrl(normalizeRepeatedSlashes(req.url!))\n return {\n parsedUrl,\n resHeaders,\n finished: true,\n statusCode: 308,\n }\n}\n```\n\n`normalizeRepeatedSlashes` is the function imported from `packages/next/src/shared/lib/utils.ts`:\n\n```ts\nexport function normalizeRepeatedSlashes(url: string) {\n const urlParts = url.split('?')\n const urlNoQuery = urlParts[0]\n\n return (\n urlNoQuery\n .replace(/\\\\/g, '/')\n .replace(/\\/\\/+/g, '/') +\n (urlParts[1] ? `?${urlParts.slice(1).join('?')}` : '')\n )\n}\n```\n\nThe 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.\n\nEvery 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:\n\n```js\n{\n finished: true,\n statusCode: 308,\n parsedUrl: parseUrl('http:/internal-target:6379/path'),\n matchedOutput: null,\n resHeaders: {}\n}\n```\n\n`parseUrl` is the legacy Node.js `url.parse`. On a URL with one slash after the protocol, it produces:\n\n```\n> url.parse('http:/internal-target:6379/path').protocol\n'http:'\n> url.parse('http:/internal-target:6379/path').slashes\nfalse\n> url.parse('http:/internal-target:6379/path').host\nnull\n> url.parse('http:/internal-target:6379/path').pathname\n'/internal-target:6379/path'\n```\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\n## One PoC ran the patch. Three inferred it.\n\nThe most starred public PoC, [`ynsmroztas/nextssrf`](https://github.com/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/` 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.\n\nThe second PoC, [`dwisiswant0/next-16.2.4-pocs`](https://github.com/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.\"\n\nThe third PoC, [`tocong282/CVE-2026-44578-PoC`](https://github.com/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.\n\nThe fourth PoC, [`panchocosil/verify-ghsa-c4j6-fc7j-m34r`](https://github.com/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 `next@15.5.15` and `next@15.5.16`, 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.\n\nThe 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.\n\nThree 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.\n\n## fail-open-intercept, with the gate's verdict in a field the action never read\n\nThe pattern catalog names this shape [Fail Open Intercept](/patterns/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](/posts/tomcat-encryptinterceptor-fails-open), 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.\n\nNext.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.\n\nThe 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.\n\n## Vercel-hosted is not affected because the upgrade handler is not the upgrade handler there\n\nThe 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.\n\nThis 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.\n\n## The reachable surface is localhost, which is not nothing\n\nThe 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:\n\n- Sidecar containers in the same pod that listen on `127.0.0.1` and trust localhost-originated requests. Admin panels, log shippers, service-mesh control planes, internal metrics endpoints.\n- Docker daemon over HTTP on `127.0.0.1:80` in development containers.\n- Co-located HTTP services with attacker-controlled URI path. The empirical verifier's `--scan` mode walks `/server-status`, `/.env`, `/actuator/env`, `/debug/pprof/`, `/containers/json`, and returns the response bodies back through the upgrade socket.\n\nThe 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.\n\nDefenders 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.\n\nThe 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.\n\nPoC: [panchocosil/verify-ghsa-c4j6-fc7j-m34r](https://github.com/panchocosil/verify-ghsa-c4j6-fc7j-m34r)","closing_line":"The advisory names cloud metadata. The bug stops at localhost.","hook_md":"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 `next@15.5.15`, 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.","post_id":296,"slug":"nextjs-cve-2026-44578-pinned-to-localhost","title":"CVE-2026-44578: The Advisory Names Cloud Metadata. The Bug Pins to Localhost.","type":"initial","unreadable_sentence":"Three PoC authors inferred the primitive from the patch. One PoC author ran the patch."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaut0QwAKCRDeZjl4jgkQ Ju7CAQCGjccKPDaWdm8cIgsjs21W9afqis4SXcQy6kDTGiP5OgEAtHJuAIuspCGK yKoQk/UNuJ+8a6wOWvqPGVgxteaicwI= =v4Jh -----END PGP SIGNATURE-----