Denial of Service (DoS) via malicious prefetch handling in router
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')
+ })
+})