Skip to content

Commit 9c8353b

Browse files
authored
fix: RSC responses when using middleware rewrites or redirects for cacheable page being served for html requests (#2843)
* test: add test cases for redirects/rewrites to cached page * fix: RSC responses when using middleware rewrites or redirects for cacheable page being served for html requests
1 parent f4b3a7b commit 9c8353b

File tree

8 files changed

+172
-11
lines changed

8 files changed

+172
-11
lines changed

src/run/headers.test.ts

+42-8
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('headers', () => {
4040

4141
expect(headers.set).toBeCalledWith(
4242
'netlify-vary',
43-
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging,cookie=__prerender_bypass|__next_preview_data',
43+
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc,cookie=__prerender_bypass|__next_preview_data',
4444
)
4545
})
4646

@@ -56,7 +56,7 @@ describe('headers', () => {
5656

5757
expect(headers.set).toBeCalledWith(
5858
'netlify-vary',
59-
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|Accept|Accept-Language,cookie=__prerender_bypass|__next_preview_data',
59+
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc|accept|accept-language,cookie=__prerender_bypass|__next_preview_data',
6060
)
6161
})
6262

@@ -77,7 +77,7 @@ describe('headers', () => {
7777

7878
expect(headers.set).toBeCalledWith(
7979
'netlify-vary',
80-
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging,cookie=__prerender_bypass|__next_preview_data',
80+
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc,cookie=__prerender_bypass|__next_preview_data',
8181
)
8282
})
8383

@@ -97,7 +97,7 @@ describe('headers', () => {
9797

9898
expect(headers.set).toBeCalledWith(
9999
'netlify-vary',
100-
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging,cookie=__prerender_bypass|__next_preview_data',
100+
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc,cookie=__prerender_bypass|__next_preview_data',
101101
)
102102
})
103103

@@ -117,7 +117,7 @@ describe('headers', () => {
117117

118118
expect(headers.set).toBeCalledWith(
119119
'netlify-vary',
120-
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging,language=en|de|fr,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE',
120+
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc,language=en|de|fr,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE',
121121
)
122122
})
123123

@@ -138,7 +138,7 @@ describe('headers', () => {
138138

139139
expect(headers.set).toBeCalledWith(
140140
'netlify-vary',
141-
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging,language=en|de|fr,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE',
141+
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc,language=en|de|fr,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE',
142142
)
143143
})
144144

@@ -161,7 +161,7 @@ describe('headers', () => {
161161

162162
expect(headers.set).toBeCalledWith(
163163
'netlify-vary',
164-
'query,header=x-nextjs-data|x-next-debug-logging|x-custom-header,language=en|de|fr|es,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE|ab_test,country=es',
164+
'query,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc|x-custom-header,language=en|de|fr|es,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE|ab_test,country=es',
165165
)
166166
})
167167

@@ -185,10 +185,44 @@ describe('headers', () => {
185185

186186
expect(headers.set).toBeCalledWith(
187187
'netlify-vary',
188-
'query=__nextDataReq|_rsc|item_id|page|per_page,header=x-nextjs-data|x-next-debug-logging|x-custom-header,language=en|de|fr|es,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE|ab_test,country=es',
188+
'query=__nextDataReq|_rsc|item_id|page|per_page,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc|x-custom-header,language=en|de|fr|es,cookie=__prerender_bypass|__next_preview_data|NEXT_LOCALE|ab_test,country=es',
189189
)
190190
})
191191
})
192+
193+
test('with vary headers provided by Next.js before 15.3.0', () => {
194+
const headers = new Headers({
195+
// before https://github.com/vercel/next.js/pull/77797 Next.js was producing following headers
196+
Vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url',
197+
})
198+
const request = new Request(defaultUrl)
199+
vi.spyOn(headers, 'set')
200+
201+
setVaryHeaders(headers, request, defaultConfig)
202+
203+
expect(headers.set).toBeCalledWith(
204+
'netlify-vary',
205+
'query=__nextDataReq|_rsc,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc,cookie=__prerender_bypass|__next_preview_data',
206+
)
207+
})
208+
209+
test('with vary headers provided by Next.js before 15.3.0 and user defined Netlify-vary', () => {
210+
const headers = new Headers({
211+
// before https://github.com/vercel/next.js/pull/77797 Next.js was producing following headers
212+
Vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url',
213+
'Netlify-Vary':
214+
'query=item_id|page|per_page,header=x-custom-header,language=es,country=es,cookie=ab_test',
215+
})
216+
const request = new Request(defaultUrl)
217+
vi.spyOn(headers, 'set')
218+
219+
setVaryHeaders(headers, request, defaultConfig)
220+
221+
expect(headers.set).toBeCalledWith(
222+
'netlify-vary',
223+
'query=__nextDataReq|_rsc|item_id|page|per_page,header=x-nextjs-data|x-next-debug-logging|next-router-prefetch|next-router-segment-prefetch|next-router-state-tree|next-url|rsc|x-custom-header,language=es,cookie=__prerender_bypass|__next_preview_data|ab_test,country=es',
224+
)
225+
})
192226
})
193227

194228
describe('setCacheControlHeaders', () => {

src/run/headers.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ const generateNetlifyVaryValues = ({
3939
}
4040
}
4141
if (header.length !== 0) {
42-
values.push(`header=${header.join(`|`)}`)
42+
const uniqueHeaderNames = [
43+
...new Set(
44+
header.map((headerName) =>
45+
// header names are case insensitive
46+
headerName.toLowerCase(),
47+
),
48+
),
49+
]
50+
values.push(`header=${uniqueHeaderNames.join(`|`)}`)
4351
}
4452
if (language.length !== 0) {
4553
values.push(`language=${language.join(`|`)}`)
@@ -78,7 +86,19 @@ export const setVaryHeaders = (
7886
{ basePath, i18n }: Pick<NextConfigComplete, 'basePath' | 'i18n'>,
7987
) => {
8088
const netlifyVaryValues: NetlifyVaryValues = {
81-
header: ['x-nextjs-data', 'x-next-debug-logging'],
89+
header: [
90+
'x-nextjs-data',
91+
'x-next-debug-logging',
92+
// using _rsc query param might not be enough because it is stripped for middleware redirect and rewrites
93+
// so adding all request headers that are used to produce the _rsc query param
94+
// https://github.com/vercel/next.js/blob/e5fe535ed17cee5e1d5576ccc33e4c49b5da1273/packages/next/src/client/components/router-reducer/set-cache-busting-search-param.ts#L32-L39
95+
'Next-Router-Prefetch',
96+
'Next-Router-Segment-Prefetch',
97+
'Next-Router-State-Tree',
98+
'Next-Url',
99+
// and exact header that actually instruct Next.js to produce RSC response
100+
'RSC',
101+
],
82102
language: [],
83103
cookie: ['__prerender_bypass', '__next_preview_data'],
84104
query: ['__nextDataReq', '_rsc'],

tests/e2e/edge-middleware.test.ts

+57-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect } from '@playwright/test'
1+
import { expect, Response } from '@playwright/test'
22
import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
33
import { test } from '../utils/playwright-helpers.js'
44
import { getImageSize } from 'next/dist/server/image-optimizer.js'
@@ -232,3 +232,59 @@ test("requests with x-middleware-subrequest don't skip middleware (GHSA-f82v-jwr
232232
// ensure we are testing version before the fix for self hosted
233233
expect(response.headers.get('x-test-used-next-version')).toBe('15.2.2')
234234
})
235+
236+
test.describe('RSC cache poisoning', () => {
237+
test('Middleware rewrite', async ({ page, middleware }) => {
238+
const prefetchResponsePromise = new Promise<Response>((resolve) => {
239+
page.on('response', (response) => {
240+
if (response.url().includes('/test/rewrite-to-cached-page')) {
241+
resolve(response)
242+
}
243+
})
244+
})
245+
await page.goto(`${middleware.url}/link-to-rewrite-to-cached-page`)
246+
247+
// ensure prefetch
248+
await page.hover('text=NextResponse.rewrite')
249+
250+
// wait for prefetch request to finish
251+
const prefetchResponse = await prefetchResponsePromise
252+
253+
// ensure prefetch respond with RSC data
254+
expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
255+
expect(prefetchResponse.headers()['netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
256+
257+
const htmlResponse = await page.goto(`${middleware.url}/test/rewrite-to-cached-page`)
258+
259+
// ensure we get HTML response
260+
expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
261+
expect(htmlResponse?.headers()['netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
262+
})
263+
264+
test('Middleware redirect', async ({ page, middleware }) => {
265+
const prefetchResponsePromise = new Promise<Response>((resolve) => {
266+
page.on('response', (response) => {
267+
if (response.url().includes('/caching-redirect-target')) {
268+
resolve(response)
269+
}
270+
})
271+
})
272+
await page.goto(`${middleware.url}/link-to-redirect-to-cached-page`)
273+
274+
// ensure prefetch
275+
await page.hover('text=NextResponse.redirect')
276+
277+
// wait for prefetch request to finish
278+
const prefetchResponse = await prefetchResponsePromise
279+
280+
// ensure prefetch respond with RSC data
281+
expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
282+
expect(prefetchResponse.headers()['netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
283+
284+
const htmlResponse = await page.goto(`${middleware.url}/test/redirect-to-cached-page`)
285+
286+
// ensure we get HTML response
287+
expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
288+
expect(htmlResponse?.headers()['netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
289+
})
290+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default function CachingRedirect() {
2+
return (
3+
<main>
4+
<h1>Hello redirect target</h1>
5+
</main>
6+
)
7+
}
8+
9+
export const dynamic = 'force-static'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default function CachingRewrite() {
2+
return (
3+
<main>
4+
<h1>Hello rewrite target</h1>
5+
</main>
6+
)
7+
}
8+
9+
export const dynamic = 'force-static'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Link from 'next/link'
2+
3+
export default function LinksToRedirectedCachedPage() {
4+
return (
5+
<nav>
6+
<ul>
7+
<li>
8+
<Link href="/test/redirect-to-cached-page">NextResponse.redirect</Link>
9+
</li>
10+
</ul>
11+
</nav>
12+
)
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Link from 'next/link'
2+
3+
export default function LinksToRewrittenCachedPage() {
4+
return (
5+
<nav>
6+
<ul>
7+
<li>
8+
<Link href="/test/rewrite-to-cached-page">NextResponse.rewrite</Link>
9+
</li>
10+
</ul>
11+
</nav>
12+
)
13+
}

tests/fixtures/middleware/middleware.ts

+7
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ const getResponse = (request: NextRequest) => {
8080
})
8181
}
8282

83+
if (request.nextUrl.pathname === '/test/rewrite-to-cached-page') {
84+
return NextResponse.rewrite(new URL('/caching-rewrite-target', request.url))
85+
}
86+
if (request.nextUrl.pathname === '/test/redirect-to-cached-page') {
87+
return NextResponse.redirect(new URL('/caching-redirect-target', request.url))
88+
}
89+
8390
return NextResponse.json({ error: 'Error' }, { status: 500 })
8491
}
8592

0 commit comments

Comments
 (0)