Denial of Service (DoS) via malicious prefetch handling in router

HIGH
vercel/next.js
Commit: f2ddd134e70f
Affected: <=16.2.1
2026-06-17 16:47 UTC

Description

The commit fixes a DoS vector where a non-RSC HTML request bearing the Next-Router-Prefetch header could be treated as a prefetch, entering a partial prefetch tree and causing HTML rendering to suspend until a timeout. The fix ensures isRSCRequest is determined before parsing prefetch modes and requires isRSCRequest for prefetch values 1, 2, and 3, including route-tree prefetch classification. It also adds a dedicated regression fixture that exercises non-RSC prefetch handling to ensure HTML responses are not blocked and that RSC prefetches remain functional.

Proof of Concept

Proof-of-concept (pre-fix exploitation path) Reproduction steps on a vulnerable Next.js 16.2.x app (before this commit): 1) Run the app locally (e.g., npm run build && npm run start) on http://localhost:3000. 2) Send an HTML request with a router prefetch header: curl -sS http://localhost:3000/ -H "Next-Router-Prefetch: 1" -H "Accept: text/html" -m 5 3) Observe that the response may hang or take an unusually long time to complete, as the request could be routed into a partial prefetch tree and render suspension occurs until timeout. Expected behavior after the fix (this commit): - Non-RSC HTML requests with Next-Router-Prefetch: 1 are ignored for prefetch handling. - The HTML response is returned promptly (200) without being blocked by prefetch logic. Optional Node.js PoC to measure response timing (pre-fix would show longer times; post-fix should be near baseline): // repro_before_fix.js (pre-fix behavior) const http = require('http'); const start = Date.now(); http.get({ hostname: 'localhost', port: 3000, path: '/', headers: { 'Next-Router-Prefetch': '1', 'Accept': 'text/html' } }, (res) => { console.log('status:', res.statusCode, 'elapsed(ms):', Date.now() - start); res.resume(); }).on('error', (e) => { console.error('error', e); }); // repro_after_fix.js (post-fix behavior) // Same as above; expect a prompt response (status 200) and normal HTML content. Curl-based quick check: - Pre-fix expectation: a noticeably slow response or hang under 5s timeout. - Post-fix expectation: quick 200 response with text/html content. Notes: - The test fixture added in this commit (non-rsc-router-prefetch) validates that non-RSC HTML requests ignore the prefetch header for HTML and that RSC prefetches remain valid when an RSC header is present.

Commit Details

Author: Tim Neutkens

Date: 2026-06-17 15:41 UTC

Message:

Fix non-RSC router prefetch handling (#94553) ### What? Ignore router prefetch protocol headers when the request is not classified as an RSC request. Add a dedicated force-dynamic App Router fixture that verifies HTML and valid RSC prefetch behavior. ### Why? A non-RSC HTML request with `Next-Router-Prefetch: 1` could enter the partial prefetch tree path, produce a null active cache node, and suspend the HTML render until timeout. ### How? Determine `isRSCRequest` before parsing prefetch modes, then require it for prefetch modes `1`, `2`, and `3`, including route-tree prefetch classification. The regression fixture uses an abort timeout to fail quickly if the HTML response hangs and separately confirms RSC prefetches remain valid. ### Verification - `pnpm build-all` - `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-turbo test/e2e/app-dir/non-rsc-router-prefetch/non-rsc-router-prefetch.test.ts` - `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-webpack test/e2e/app-dir/non-rsc-router-prefetch/non-rsc-router-prefetch.test.ts` - `pnpm --filter=next types` <!-- NEXT_JS_LLM_PR -->

Triage Assessment

Vulnerability Type: Denial of Service (DoS)

Confidence: HIGH

Reasoning:

The patch adjusts prefetch handling so that non-RSC HTML requests are no longer treated as prefetchable. Previously, a Next-Router-Prefetch: 1 header on an HTML request could route into a partial prefetch tree and suspend HTML rendering until a timeout, which could enable a denial-of-service-like condition. By requiring isRSCRequest for prefetch modes and parsing, the fix reduces a potential attack surface related to request handling and rendering hangs.

Verification Assessment

Vulnerability Type: Denial of Service (DoS) via malicious prefetch handling in router

Confidence: HIGH

Affected Versions: <=16.2.1

Code Diff

diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index e8ff2c8d44e9..0356e0c43f53 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -397,21 +397,24 @@ function parseRequestHeaders( headers: IncomingHttpHeaders, options: ParseRequestHeadersOptions ): ParsedRequestHeaders { + const isRSCRequest = isRSCRequestHeader(headers[RSC_HEADER]) + // runtime prefetch requests are *not* treated as prefetch requests // (TODO: this is confusing, we should refactor this to express this better) - const isPrefetchRequest = headers[NEXT_ROUTER_PREFETCH_HEADER] === '1' + const isPrefetchRequest = + isRSCRequest && headers[NEXT_ROUTER_PREFETCH_HEADER] === '1' - const isAppShellPrefetchRequest = headers[NEXT_ROUTER_PREFETCH_HEADER] === '3' + const isAppShellPrefetchRequest = + isRSCRequest && headers[NEXT_ROUTER_PREFETCH_HEADER] === '3' // App Shell prefetches are a subtype of runtime prefetch — same code path, // but with less resolved content (omitting link data) const isRuntimePrefetchRequest = - headers[NEXT_ROUTER_PREFETCH_HEADER] === '2' || isAppShellPrefetchRequest + isRSCRequest && + (headers[NEXT_ROUTER_PREFETCH_HEADER] === '2' || isAppShellPrefetchRequest) const isHmrRefresh = headers[NEXT_HMR_REFRESH_HEADER] !== undefined - const isRSCRequest = isRSCRequestHeader(headers[RSC_HEADER]) - const shouldProvideFlightRouterState = isRSCRequest && (!isPrefetchRequest || !options.isRoutePPREnabled) @@ -421,7 +424,7 @@ function parseRequestHeaders( // Checks if this is a prefetch of the Route Tree by the Segment Cache const isRouteTreePrefetchRequest = - headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] === '/_tree' + isRSCRequest && headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] === '/_tree' const csp = headers['content-security-policy'] || diff --git a/test/e2e/app-dir/non-rsc-router-prefetch/app/layout.tsx b/test/e2e/app-dir/non-rsc-router-prefetch/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/non-rsc-router-prefetch/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + <html> + <body>{children}</body> + </html> + ) +} diff --git a/test/e2e/app-dir/non-rsc-router-prefetch/app/page.tsx b/test/e2e/app-dir/non-rsc-router-prefetch/app/page.tsx new file mode 100644 index 000000000000..09ff826d3c51 --- /dev/null +++ b/test/e2e/app-dir/non-rsc-router-prefetch/app/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic' + +export default function Page() { + return <p>hello world</p> +} diff --git a/test/e2e/app-dir/non-rsc-router-prefetch/next.config.js b/test/e2e/app-dir/non-rsc-router-prefetch/next.config.js new file mode 100644 index 000000000000..11f37702f95e --- /dev/null +++ b/test/e2e/app-dir/non-rsc-router-prefetch/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: false, + experimental: { + cachedNavigations: false, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/non-rsc-router-prefetch/non-rsc-router-prefetch.test.ts b/test/e2e/app-dir/non-rsc-router-prefetch/non-rsc-router-prefetch.test.ts new file mode 100644 index 000000000000..fb72514a592c --- /dev/null +++ b/test/e2e/app-dir/non-rsc-router-prefetch/non-rsc-router-prefetch.test.ts @@ -0,0 +1,47 @@ +import { nextTestSetup } from 'e2e-utils' +import { + NEXT_ROUTER_PREFETCH_HEADER, + RSC_HEADER, +} from 'next/dist/client/components/app-router-headers' + +describe('non-rsc-router-prefetch', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + beforeAll(async () => { + const res = await next.fetch('/') + await res.text() + }) + + it('ignores the router prefetch header for HTML requests', async () => { + const res = await next.fetch('/', { + headers: { + [NEXT_ROUTER_PREFETCH_HEADER]: '1', + }, + signal: AbortSignal.timeout(5_000), + }) + const html = await res.text() + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/html') + expect(html).toContain('hello world') + }) + + it('honors the router prefetch header for RSC requests', async () => { + const res = await next.fetch('/', { + headers: { + [RSC_HEADER]: '1', + [NEXT_ROUTER_PREFETCH_HEADER]: '1', + }, + }) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/x-component') + }) +})
← Back to Alerts View on GitHub →