Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 9c8353b

Browse filesBrowse 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
Copy full SHA for 9c8353b

File tree

Expand file treeCollapse file tree

8 files changed

+172
-11
lines changed
Filter options
Expand file treeCollapse file tree

8 files changed

+172
-11
lines changed

‎src/run/headers.test.ts

Copy file name to clipboardExpand all lines: src/run/headers.test.ts
+42-8Lines changed: 42 additions & 8 deletions
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

Copy file name to clipboardExpand all lines: src/run/headers.ts
+22-2Lines changed: 22 additions & 2 deletions
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

Copy file name to clipboardExpand all lines: tests/e2e/edge-middleware.test.ts
+57-1Lines changed: 57 additions & 1 deletion
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+
})
+9Lines changed: 9 additions & 0 deletions
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'
+9Lines changed: 9 additions & 0 deletions
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'
+13Lines changed: 13 additions & 0 deletions
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+
}
+13Lines changed: 13 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: tests/fixtures/middleware/middleware.ts
+7Lines changed: 7 additions & 0 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.