-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## Middleware was where the docs told you to put auth\n\nVercel introduced middleware in Next.js 11.1.4. The pitch was that you could intercept every request before the route handler ran, and redirect, rewrite, or block. The first example in the launch post was an authentication wall. The first example in every Next.js tutorial since has been an authentication wall. The official `vercel/examples` repository contains roughly a dozen `middleware.ts` files; nearly all of them do auth.\n\nThe shape that Vercel taught the ecosystem was:\n\n```ts\n// middleware.ts\nimport { NextRequest, NextResponse } from 'next/server'\n\nexport function middleware(req: NextRequest) {\n const session = req.cookies.get('session')\n if (!session) {\n return NextResponse.redirect(new URL('/login', req.url))\n }\n}\n\nexport const config = { matcher: ['/admin/:path*', '/api/internal/:path*'] }\n```\n\nIf `middleware` runs, the auth check runs. If `middleware` does not run, the auth check does not run, and the request proceeds straight to the matched route. That is the contract Vercel taught.\n\n## The recursion guard reads a header\n\nA Next.js middleware can call `fetch()`. A `fetch()` to a path on the same site will hit middleware again. That second invocation can `fetch()` again. Without a brake, you can stack-overflow your own server by writing one wrong `middleware.ts`. Vercel handled this with a header.\n\nEvery outbound `fetch()` from inside middleware appends the current module's name to a colon-separated list in `x-middleware-subrequest`. On every inbound request, before invoking the middleware, the runtime reads the header, splits on `:`, counts how many times the current module's name appears, and returns \"skip middleware\" if the count crosses a threshold. The vulnerable code, from `packages/next/src/server/web/sandbox/sandbox.ts` in v14.2.24:\n\n```ts\nexport const run = withTaggedErrors(async function runWithTaggedErrors(params) {\n const runtime = await getRuntimeContext(params)\n const subreq = params.request.headers[`x-middleware-subrequest`]\n const subrequests = typeof subreq === 'string' ? subreq.split(':') : []\n\n const MAX_RECURSION_DEPTH = 5\n const depth = subrequests.reduce(\n (acc, curr) => (curr === params.name ? acc + 1 : acc),\n 0\n )\n\n if (depth >= MAX_RECURSION_DEPTH) {\n return {\n waitUntil: Promise.resolve(),\n response: new runtime.context.Response(null, {\n headers: {\n 'x-middleware-next': '1',\n },\n }),\n }\n }\n\n // ... edge function loaded and invoked here, only if depth < MAX_RECURSION_DEPTH\n```\n\nRead the lines in order. Line 3 reads `params.request.headers['x-middleware-subrequest']` directly: no filtering, no origin check, no allowlist. Lines 7 through 10 count occurrences of `params.name`. Lines 12 through 21 return early with the response header `x-middleware-next: 1`, which is the runtime's instruction to \"the middleware finished and decided not to interrupt; proceed to the matched route.\"\n\nThe runtime's instruction is \"skip middleware.\" The decision input is one inbound header.\n\n## Six colons, no auth, your route runs\n\n`params.name` for an app-router root middleware is the manifest name `middleware`. For different layouts the value can be `pages/_middleware`, `src/middleware`, `src/middleware/index`. The exact value lives in `.next/server/middleware-manifest.json` after a build, or in the Next.js source if you are working from outside the deployment. The exploit is one curl:\n\n```bash\ncurl -i 'https://target.example.com/admin/users' \\\n -H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware'\n```\n\nServer-side, `subreq` is the supplied string. `subrequests` is the five-element array. `depth` is 5. `5 >= MAX_RECURSION_DEPTH` is true. The function returns a response carrying `x-middleware-next: 1`. The Next.js server reads that header, decides middleware is done, and routes the request to `/admin/users`. The middleware module that contained your `if (!session) redirect('/login')` is never loaded.\n\nThe route handler runs as if the auth gate had let it through. The auth gate did not let it through. The auth gate did not run.\n\nThe exact module name is the only piece that varies by deployment. Public exploit code from disclosure week iterated through `middleware`, `src/middleware`, `pages/_middleware`, `src/pages/_middleware`, with five repetitions of each, in a single header. There is no rate limit on auth-bypassed endpoints, because, from the application's perspective, no auth check failed.\n\n## The 2024 commit moved the threshold from one to five\n\nOpen the file's git history. The recursion threshold was added on February 6, 2024 by commit `6d07c00`, titled \"fix: allow some recursion for middleware subrequests.\" Before the commit, the check was a flat membership test. After it, a count-and-compare:\n\n```diff\n- if (subrequests.includes(params.name)) {\n- return skipResponse\n- }\n+ const MAX_RECURSION_DEPTH = 5\n+ const depth = subrequests.reduce(\n+ (acc, curr) => (curr === params.name ? acc + 1 : acc),\n+ 0\n+ )\n+ if (depth >= MAX_RECURSION_DEPTH) {\n+ return skipResponse\n+ }\n```\n\nBefore February 2024, the bypass required exactly one occurrence of the middleware name in the header. After February 2024, it required five. The PR description does not mention security. The PR description discusses an internal Slack thread about Vercel-production behavior and a screenshot comparison of \"no forwarding\" vs. \"with forwarding.\"\n\nA Vercel engineer, in the same PR's notes, wrote: \"Currently limited by fetches having to forward the subrequest header for each request which isn't ideal. Need some assistant on how to access the request in the module context fetch override.\"\n\nThe note reads as architectural awkwardness. It is a description of the bug. The header is forwarded on every fetch because the framework has no other channel for this signal. The framework has no other channel because the signal was designed as in-band metadata on the same HTTP-shaped pipe that carries inbound network traffic. The author asks for assistance accessing \"the request in the module context fetch override.\" Nobody answered that the right answer is to not put this signal on a request header at all.\n\nThe thirteen months between this commit and the eventual security advisory are thirteen months in which the depth counter was the only thing standing between an external caller and the middleware-skip path. Six colons instead of one.\n\n## The patch did not delete the skip path\n\nThe advisory landed March 21, 2025. The fix, commit `52a078d`, \"Update middleware request header,\" does this:\n\n```diff\n+ const randomBytes = new Uint8Array(8)\n+ crypto.getRandomValues(randomBytes)\n+ const middlewareSubrequestId = Buffer.from(randomBytes).toString('hex')\n+ ;(globalThis as any)[Symbol.for('@next/middleware-subrequest-id')] =\n+ middlewareSubrequestId\n```\n\n```diff\n+ if (\n+ header === 'x-middleware-subrequest' &&\n+ headers['x-middleware-subrequest-id'] !==\n+ (globalThis as any)[Symbol.for('@next/middleware-subrequest-id')]\n+ ) {\n+ delete headers['x-middleware-subrequest']\n+ }\n```\n\n```diff\n+ init.headers.set(\n+ 'x-middleware-subrequest-id',\n+ (globalThis as any)[Symbol.for('@next/middleware-subrequest-id')]\n+ )\n```\n\nThe patch generates an 8-byte random identifier at server startup, stashes it in a global symbol, and: on outbound subrequests originating from inside middleware, sets `x-middleware-subrequest-id` to the secret; on inbound requests, strips `x-middleware-subrequest` if `x-middleware-subrequest-id` does not match the secret.\n\nThe vulnerable code in `sandbox.ts` is not touched. The depth-count comparison still runs. The early-return that skips middleware still runs. The fix is upstream of the comparison: another check, in another file (`packages/next/src/server/lib/server-ipc/utils.ts`), that strips the dangerous header before it reaches `run()`.\n\nThe post-patch behavior, when the secret matches, is: trust the inbound header. The post-patch behavior, when the secret does not match, is: drop the header. The semantics of the header itself, the contract that \"if this header reads N occurrences of your module name, skip yourself,\" is preserved exactly.\n\nThe comment the patch author left on the new filter is: \"If this request didn't origin from this session we filter out the 'x-middleware-subrequest' header so we don't skip middleware incorrectly.\"\n\nThe framing is \"skip middleware incorrectly.\" Not \"an external caller can skip your auth check.\" The author corrects an off-by-one in trust evaluation; they do not retract the assumption that an inbound header should be empowered to skip middleware in the first place.\n\n## Internal-only by convention\n\nThree years and seven months elapsed between Next.js 11.1.4 (August 2021) and the advisory (March 2025). For all of them, an HTTP header that nobody outside the framework was supposed to know about could turn off the framework's documented authentication surface.\n\nThe header is in the framework's own source. The framework is open source. The bypass is one search away from anyone who reads `packages/next/src/server/web/sandbox/sandbox.ts`. The protection of the header was that nobody had read that file with the question \"what would happen if I sent this from outside\" in mind.\n\nThat is what \"internal-only\" meant in this codebase. Not a check, not an allowlist, not a network boundary, not a token. A convention that Vercel kept and the network did not.\n\nThis is the [trust inversion](/posts/the-trust-inversion) shape applied one floor down from where the catalog usually finds it. The trust artifact is not a maintainer account or a signing UI; it is a request header the framework defined as its own internal IPC. The recursion guard had authority to skip middleware. The recursion guard's input was a network-supplied string. The thing that authorized the skip was, in the framework's view, the framework. In the network's view, anyone with curl.\n\nVercel's documentation says to put authentication in middleware. Vercel's runtime says any inbound request with the right header skips middleware. Both statements are public record. Both cannot be accurate.\n\nPoC: [projectdiscovery/nuclei-templates](https://github.com/projectdiscovery/nuclei-templates/blob/main/headless/cves/2025/CVE-2025-29927-HEADLESS.yaml)","closing_line":"A convention that Vercel kept and the network did not.","hook_md":"Open the Next.js documentation page for Middleware. Scroll to \"Use Cases.\" The first bullet is \"Authentication and authorization.\" Vercel ships middleware as the place to put your auth check. Tens of thousands of Next.js applications, every starter template, every \"build a SaaS\" tutorial since version 11.1.4, put their `isAuthenticated()` redirect inside `middleware.ts`. That is the documented pattern.\n\nVercel also ships, in the same versions, a function called `run` in `packages/next/src/server/web/sandbox/sandbox.ts`. The function is the only thing between an inbound request and the middleware module. Before invoking your auth check, `run` looks at a single inbound HTTP header. If the header is set the right way, `run` returns early with a \"skip middleware\" response. The header is `x-middleware-subrequest`. The right way is six colons. The route that your middleware was guarding runs anyway.\n\nThe function is the auth bypass. The header is the auth bypass. They are public. They have been public since version 11.1.4.","post_id":56,"slug":"next-middleware-cve-2025-29927-recursion-guard-was-the-bypass","title":"CVE-2025-29927: The Recursion Guard Was the Auth Bypass","type":"initial","unreadable_sentence":"That is what \"internal-only\" meant in this codebase. Not a check, not an allowlist, not a network boundary, not a token. A convention that Vercel kept and the network did not."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCahnHZgAKCRDeZjl4jgkQ Jpt8AP9P1840o/A4N5CNBNhlqzZWh3FZrU5VZNgvnb308HQpdQD/TJlOPhZSxrCd Nk0iXrIEPucLipkB14YqCSI/1XP/5wo= =9pdU -----END PGP SIGNATURE-----