Skip to content

Commit 9c12030

Browse files
authored
Fix RSC hash validation for middleware external rewrites (vercel#82176)
1 parent 1b2e69f commit 9c12030

9 files changed

Lines changed: 240 additions & 2 deletions

File tree

packages/next/src/server/web/adapter.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
FLIGHT_HEADERS,
2020
NEXT_REWRITTEN_PATH_HEADER,
2121
NEXT_REWRITTEN_QUERY_HEADER,
22+
NEXT_RSC_UNION_QUERY,
2223
RSC_HEADER,
2324
} from '../../client/components/app-router-headers'
2425
import { ensureInstrumentationRegistered } from './globals'
@@ -166,6 +167,8 @@ export async function adapter(
166167
? new URL(params.request.url)
167168
: requestURL
168169

170+
const rscHash = normalizeURL.searchParams.get(NEXT_RSC_UNION_QUERY)
171+
169172
const request = new NextRequestHint({
170173
page: params.page,
171174
// Strip internal query parameters off the request.
@@ -397,6 +400,24 @@ export async function adapter(
397400
}
398401
}
399402

403+
/**
404+
* Always forward the `_rsc` search parameter to the rewritten URL for RSC requests,
405+
* unless it's already present. This is necessary to ensure that RSC hash validation
406+
* works correctly after a rewrite. For internal rewrites, the server can validate the
407+
* RSC hash using the original URL, so forwarding the `_rsc` parameter is less critical.
408+
* However, for external rewrites (where the request is proxied to another Next.js server),
409+
* the external server does not have access to the original URL or its search parameters.
410+
* In these cases, forwarding the `_rsc` parameter is essential so that the external server
411+
* can perform the correct RSC hash validation.
412+
*/
413+
if (response && rewrite && isRSCRequest && rscHash) {
414+
const rewriteURL = new URL(rewrite)
415+
if (!rewriteURL.searchParams.has(NEXT_RSC_UNION_QUERY)) {
416+
rewriteURL.searchParams.set(NEXT_RSC_UNION_QUERY, rscHash)
417+
response.headers.set('x-middleware-rewrite', rewriteURL.toString())
418+
}
419+
}
420+
400421
/**
401422
* For redirects we will not include the locale in case when it is the
402423
* default and we must also make sure the outgoing URL is a data one if

packages/next/src/server/web/sandbox/sandbox.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
edgeSandboxNextRequestContext,
88
} from './context'
99
import { requestToBodyStream } from '../../body-streams'
10-
import { NEXT_RSC_UNION_QUERY } from '../../../client/components/app-router-headers'
1110
import type { ServerComponentsHmrCache } from '../../response-cache'
1211
import {
1312
getBuiltinRequestContext,
@@ -117,7 +116,6 @@ export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
117116

118117
const KUint8Array = runtime.evaluate('Uint8Array')
119118
const urlInstance = new URL(params.request.url)
120-
urlInstance.searchParams.delete(NEXT_RSC_UNION_QUERY)
121119

122120
params.request.url = urlInstance.toString()
123121

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// This page won't actually be served due to middleware rewrite
2+
export default function AboutPage() {
3+
return (
4+
<div>
5+
<h1>About Page (This should not be seen)</h1>
6+
<p>This page should be rewritten to external server</p>
7+
</div>
8+
)
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function RootLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode
5+
}) {
6+
return (
7+
<html lang="en">
8+
<body>{children}</body>
9+
</html>
10+
)
11+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Link from 'next/link'
2+
3+
export default function HomePage() {
4+
return (
5+
<div>
6+
<h1>Home Page</h1>
7+
<div id="home-content">
8+
<p>This is the home page</p>
9+
<Link href="/about" id="about-link">
10+
Go to About Page
11+
</Link>
12+
</div>
13+
</div>
14+
)
15+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { createServer } from 'node:http'
2+
3+
let receivedRequests = []
4+
5+
function createExternalServer() {
6+
const server = createServer((req, res) => {
7+
const requestUrl = `${req.url}`
8+
console.log('External server received request:', requestUrl)
9+
10+
// Store the request for testing
11+
receivedRequests.push({
12+
url: requestUrl,
13+
method: req.method,
14+
headers: req.headers,
15+
timestamp: Date.now(),
16+
})
17+
18+
// Simple response
19+
res.writeHead(200, { 'Content-Type': 'text/html' })
20+
res.end(`
21+
<html>
22+
<body>
23+
<h1>External Server Response</h1>
24+
<p>Request URL: ${requestUrl}</p>
25+
<div id="external-response">External server handled the request</div>
26+
</body>
27+
</html>
28+
`)
29+
})
30+
31+
return server
32+
}
33+
34+
export async function startExternalServer(port) {
35+
receivedRequests = [] // Reset requests
36+
const server = createExternalServer()
37+
38+
const cleanup = async () => {
39+
await new Promise((resolve) => server.close(resolve))
40+
}
41+
42+
return new Promise((resolve, reject) => {
43+
server.on('error', reject)
44+
server.listen(port, () => {
45+
console.log('External server listening on port', port)
46+
resolve({ cleanup, getReceivedRequests: () => receivedRequests })
47+
})
48+
})
49+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import webdriver from 'next-webdriver'
2+
import { findPort, nextBuild, nextStart } from 'next-test-utils'
3+
import { isNextDeploy, isNextDev } from 'e2e-utils'
4+
import { startExternalServer } from './external-server.mjs'
5+
6+
describe('middleware RSC external rewrite', () => {
7+
if (isNextDev || isNextDeploy) {
8+
test('should not run during dev or deploy test runs', () => {})
9+
return
10+
}
11+
12+
let cleanup: () => Promise<void>
13+
let nextPort: number
14+
let externalServerManager: {
15+
cleanup: () => Promise<void>
16+
getReceivedRequests: () => any[]
17+
}
18+
19+
beforeAll(async () => {
20+
const appDir = __dirname
21+
await nextBuild(appDir, undefined, { cwd: appDir })
22+
23+
// Start external server first
24+
const externalPort = await findPort()
25+
process.env.EXTERNAL_SERVER_PORT = externalPort.toString()
26+
externalServerManager = await startExternalServer(externalPort)
27+
28+
// Start Next.js server
29+
nextPort = await findPort()
30+
const nextApp = await nextStart(appDir, nextPort, {
31+
env: {
32+
...process.env,
33+
EXTERNAL_SERVER_PORT: externalPort.toString(),
34+
},
35+
})
36+
37+
cleanup = async () => {
38+
await nextApp.kill()
39+
await externalServerManager.cleanup()
40+
}
41+
})
42+
43+
afterAll(async () => {
44+
if (cleanup) {
45+
await cleanup()
46+
}
47+
})
48+
49+
test('should forward _rsc parameter to external server on RSC navigation', async () => {
50+
let browser
51+
52+
try {
53+
browser = await webdriver(nextPort, '/')
54+
55+
// Verify we're on the home page
56+
const homeContent = await browser.elementById('home-content')
57+
expect(await homeContent.text()).toContain('This is the home page')
58+
59+
// Clear any previous requests
60+
const initialRequests = externalServerManager.getReceivedRequests()
61+
console.log('Initial requests before navigation:', initialRequests.length)
62+
63+
// Click the link to /about which should trigger RSC navigation
64+
const aboutLink = await browser.elementById('about-link')
65+
await aboutLink.click()
66+
67+
// Wait a bit for the request to be processed
68+
await browser.waitForElementByCss('#external-response', 5000)
69+
70+
// Check that external server received the request
71+
const receivedRequests = externalServerManager.getReceivedRequests()
72+
console.log('Total requests received:', receivedRequests.length)
73+
console.log(
74+
'Received requests:',
75+
receivedRequests.map((r) => ({ url: r.url, method: r.method }))
76+
)
77+
78+
// Find requests that contain _rsc parameter
79+
const rscRequests = receivedRequests.filter((req) =>
80+
req.url.includes('_rsc=')
81+
)
82+
console.log(
83+
'RSC requests:',
84+
rscRequests.map((r) => r.url)
85+
)
86+
87+
// Verify that at least one request contains the _rsc parameter
88+
expect(rscRequests.length).toBeGreaterThan(0)
89+
90+
// Verify the external server response is displayed
91+
const externalResponse = await browser.elementById('external-response')
92+
expect(await externalResponse.text()).toBe(
93+
'External server handled the request'
94+
)
95+
} finally {
96+
if (browser) {
97+
await browser.close()
98+
}
99+
}
100+
})
101+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
export function middleware(request: NextRequest) {
4+
const url = request.nextUrl.clone()
5+
6+
if (url.pathname === '/about') {
7+
// Get the external server port from environment or default
8+
const externalPort = process.env.EXTERNAL_SERVER_PORT || '3001'
9+
const externalUrl = `http://localhost:${externalPort}/about`
10+
11+
console.log('Middleware rewriting /about to:', externalUrl)
12+
13+
// Rewrite to external server
14+
return NextResponse.rewrite(externalUrl)
15+
}
16+
17+
return NextResponse.next()
18+
}
19+
20+
export const config = {
21+
matcher: ['/about'],
22+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
experimental: {
6+
logging: {
7+
level: 'verbose',
8+
},
9+
},
10+
}
11+
12+
module.exports = nextConfig

0 commit comments

Comments
 (0)