Skip to content

Commit 28a9fc9

Browse files
committed
Check cache busting search params on all RSC requests
1 parent 614feb0 commit 28a9fc9

File tree

5 files changed

+172
-54
lines changed

5 files changed

+172
-54
lines changed

packages/next/src/server/base-server.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,27 +2055,19 @@ export default abstract class Server<
20552055
const isPossibleServerAction = getIsPossibleServerAction(req)
20562056
const hasGetInitialProps = !!components.Component?.getInitialProps
20572057
let isSSG = !!components.getStaticProps
2058+
// NOTE: Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later
2059+
const isRSCRequest = getRequestMeta(req, 'isRSCRequest') ?? false
20582060

20592061
// Not all CDNs respect the Vary header when caching. We must assume that
20602062
// only the URL is used to vary the responses. The Next client computes a
20612063
// hash of the header values and sends it as a search param. Before
20622064
// responding to a request, we must verify that the hash matches the
20632065
// expected value. Neglecting to do this properly can lead to cache
20642066
// poisoning attacks on certain CDNs.
2065-
// TODO: This is verification only runs during per-segment prefetch
2066-
// requests, since those are the only ones that both vary on a custom
2067-
// header and are cacheable. But for safety, we should run this
2068-
// verification for all requests, once we confirm the behavior is correct.
2069-
// Will need to update our test suite, since there are a handlful of unit
2070-
// tests that send fetch requests with custom headers but without a
2071-
// corresponding cache-busting search param.
2072-
// TODO: Consider not using custom request headers at all, and instead fully
2073-
// encode everything into the search param.
20742067
if (
20752068
!this.minimalMode &&
20762069
this.nextConfig.experimental.validateRSCRequestHeaders &&
2077-
this.isAppSegmentPrefetchEnabled &&
2078-
getRequestMeta(req, 'segmentPrefetchRSCRequest')
2070+
isRSCRequest
20792071
) {
20802072
const headers = req.headers
20812073
const expectedHash = computeCacheBustingSearchParam(
@@ -2084,10 +2076,17 @@ export default abstract class Server<
20842076
headers[NEXT_ROUTER_STATE_TREE_HEADER.toLowerCase()],
20852077
headers[NEXT_URL.toLowerCase()]
20862078
)
2087-
const actualHash = getRequestMeta(req, 'cacheBustingSearchParam') ?? null
2079+
const actualHash =
2080+
getRequestMeta(req, 'cacheBustingSearchParam') ??
2081+
new URL(
2082+
req.url || '',
2083+
`http://${req.headers.host || 'localhost'}`
2084+
).searchParams.get(NEXT_RSC_UNION_QUERY)
2085+
20882086
if (expectedHash !== actualHash) {
20892087
// The hash sent by the client does not match the expected value.
20902088
// Respond with an error.
2089+
// TODO: Change it to a redirect to the URL with the correct cache-busting search param.
20912090
res.statusCode = 400
20922091
res.setHeader('content-type', 'text/plain')
20932092
res.body('').send()
@@ -2173,10 +2172,6 @@ export default abstract class Server<
21732172
const isPrefetchRSCRequest =
21742173
getRequestMeta(req, 'isPrefetchRSCRequest') ?? false
21752174

2176-
// NOTE: Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later
2177-
2178-
const isRSCRequest = getRequestMeta(req, 'isRSCRequest') ?? false
2179-
21802175
// when we are handling a middleware prefetch and it doesn't
21812176
// resolve to a static data route we bail early to avoid
21822177
// unexpected SSR invocations

test/e2e/app-dir/app-prefetch/prefetching.test.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { nextTestSetup } from 'e2e-utils'
22
import { check, waitFor, retry } from 'next-test-utils'
33
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
4+
import { computeCacheBustingSearchParam } from 'next/dist/shared/lib/router/utils/cache-busting-search-param'
45

56
const browserConfigWithFixedTime = {
67
beforePageLoad: (page) => {
@@ -294,14 +295,26 @@ describe('app dir - prefetching', () => {
294295
true,
295296
])
296297
)
297-
const response = await next.fetch(`/prefetch-auto/justputit?_rsc=dcqtr`, {
298-
headers: {
299-
RSC: '1',
300-
'Next-Router-Prefetch': '1',
301-
'Next-Router-State-Tree': stateTree,
302-
'Next-Url': '/prefetch-auto/vercel',
303-
},
304-
})
298+
299+
const headers = {
300+
RSC: '1',
301+
'Next-Router-Prefetch': '1',
302+
'Next-Router-State-Tree': stateTree,
303+
'Next-Url': '/prefetch-auto/vercel',
304+
}
305+
306+
const url = new URL('/prefetch-auto/justputit', 'http://localhost')
307+
const cacheBustingParam = computeCacheBustingSearchParam(
308+
headers['Next-Router-Prefetch'],
309+
undefined,
310+
headers['Next-Router-State-Tree'],
311+
headers['Next-Url']
312+
)
313+
if (cacheBustingParam) {
314+
url.searchParams.set('_rsc', cacheBustingParam)
315+
}
316+
317+
const response = await next.fetch(url.toString(), { headers })
305318

306319
const prefetchResponse = await response.text()
307320
expect(prefetchResponse).not.toContain('Page Data!')
Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { nextTestSetup } from 'e2e-utils'
2+
import { computeCacheBustingSearchParam } from 'next/dist/shared/lib/router/utils/cache-busting-search-param'
23

34
describe('app dir - validation', () => {
45
const { next, skipped } = nextTestSetup({
@@ -11,20 +12,47 @@ describe('app dir - validation', () => {
1112
}
1213

1314
it('should error when passing invalid router state tree', async () => {
14-
const res = await next.fetch('/', {
15-
headers: {
16-
RSC: '1',
17-
'Next-Router-State-Tree': JSON.stringify(['', '']),
18-
},
19-
})
15+
const stateTree1 = JSON.stringify(['', ''])
16+
const stateTree2 = JSON.stringify(['', {}])
17+
18+
const headers1 = {
19+
RSC: '1',
20+
'Next-Router-State-Tree': stateTree1,
21+
}
22+
23+
const headers2 = {
24+
RSC: '1',
25+
'Next-Router-State-Tree': stateTree2,
26+
}
27+
28+
const url1 = new URL('/', 'http://localhost')
29+
const url2 = new URL('/', 'http://localhost')
30+
31+
// Add cache busting search param for both requests
32+
const cacheBustingParam1 = computeCacheBustingSearchParam(
33+
undefined,
34+
undefined,
35+
stateTree1,
36+
undefined
37+
)
38+
const cacheBustingParam2 = computeCacheBustingSearchParam(
39+
undefined,
40+
undefined,
41+
stateTree2,
42+
undefined
43+
)
44+
45+
if (cacheBustingParam1) {
46+
url1.searchParams.set('_rsc', cacheBustingParam1)
47+
}
48+
if (cacheBustingParam2) {
49+
url2.searchParams.set('_rsc', cacheBustingParam2)
50+
}
51+
52+
const res = await next.fetch(url1.toString(), { headers: headers1 })
2053
expect(res.status).toBe(500)
2154

22-
const res2 = await next.fetch('/', {
23-
headers: {
24-
RSC: '1',
25-
'Next-Router-State-Tree': JSON.stringify(['', {}]),
26-
},
27-
})
55+
const res2 = await next.fetch(url2.toString(), { headers: headers2 })
2856
expect(res2.status).toBe(200)
2957
})
3058
})

test/e2e/app-dir/ppr-full/ppr-full.test.ts

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { measurePPRTimings } from 'e2e-utils/ppr'
33
import { links } from './components/links'
44
import cheerio from 'cheerio'
55
import { retry } from 'next-test-utils'
6+
import { computeCacheBustingSearchParam } from 'next/dist/shared/lib/router/utils/cache-busting-search-param'
67

78
type Page = {
89
pathname: string
@@ -46,6 +47,26 @@ const pages: Page[] = [
4647
},
4748
]
4849

50+
const addCacheBustingSearchParam = (
51+
pathname: string,
52+
headers: Record<string, string | string[] | undefined>
53+
) => {
54+
const cacheKey = computeCacheBustingSearchParam(
55+
headers['Next-Router-Prefetch'],
56+
headers['Next-Router-Segment-Prefetch'],
57+
headers['Next-Router-State-Tree'],
58+
headers['Next-URL']
59+
)
60+
61+
if (cacheKey === null) {
62+
return pathname
63+
}
64+
65+
const url = new URL(pathname, 'http://localhost')
66+
url.searchParams.set('_rsc', cacheKey)
67+
return url.pathname + url.search
68+
}
69+
4970
describe('ppr-full', () => {
5071
const { next, isNextDev, isNextDeploy } = nextTestSetup({
5172
files: __dirname,
@@ -536,8 +557,17 @@ describe('ppr-full', () => {
536557
describe.each(pages)('for $pathname', ({ pathname, revalidate }) => {
537558
it('should have correct headers', async () => {
538559
await retry(async () => {
539-
const res = await next.fetch(pathname, {
540-
headers: { RSC: '1', 'Next-Router-Prefetch': '1' },
560+
const headers = {
561+
RSC: '1',
562+
'Next-Router-Prefetch': '1',
563+
}
564+
const urlWithCacheBusting = addCacheBustingSearchParam(
565+
pathname,
566+
headers
567+
)
568+
569+
const res = await next.fetch(urlWithCacheBusting, {
570+
headers,
541571
})
542572

543573
expect(res.status).toEqual(200)
@@ -565,12 +595,20 @@ describe('ppr-full', () => {
565595

566596
it('should not contain dynamic content', async () => {
567597
const unexpected = `${Date.now()}:${Math.random()}`
568-
const res = await next.fetch(pathname, {
569-
headers: {
570-
RSC: '1',
571-
'Next-Router-Prefetch': '1',
572-
'X-Test-Input': unexpected,
573-
},
598+
const headers = {
599+
RSC: '1',
600+
'Next-Router-Prefetch': '1',
601+
'X-Test-Input': unexpected,
602+
}
603+
const urlWithCacheBusting = addCacheBustingSearchParam(
604+
pathname,
605+
headers
606+
)
607+
608+
console.log('urlWithCacheBusting', urlWithCacheBusting)
609+
610+
const res = await next.fetch(urlWithCacheBusting, {
611+
headers,
574612
})
575613
expect(res.status).toEqual(200)
576614
expect(res.headers.get('content-type')).toEqual('text/x-component')
@@ -583,8 +621,14 @@ describe('ppr-full', () => {
583621
describe('Dynamic RSC Response', () => {
584622
describe.each(pages)('for $pathname', ({ pathname, dynamic }) => {
585623
it('should have correct headers', async () => {
586-
const res = await next.fetch(pathname, {
587-
headers: { RSC: '1' },
624+
const headers = { RSC: '1' }
625+
const urlWithCacheBusting = addCacheBustingSearchParam(
626+
pathname,
627+
headers
628+
)
629+
630+
const res = await next.fetch(urlWithCacheBusting, {
631+
headers,
588632
})
589633
expect(res.status).toEqual(200)
590634
expect(res.headers.get('content-type')).toEqual('text/x-component')
@@ -601,8 +645,17 @@ describe('ppr-full', () => {
601645
if (dynamic === true || dynamic === 'force-dynamic') {
602646
it('should contain dynamic content', async () => {
603647
const expected = `${Date.now()}:${Math.random()}`
604-
const res = await next.fetch(pathname, {
605-
headers: { RSC: '1', 'X-Test-Input': expected },
648+
const headers = {
649+
RSC: '1',
650+
'X-Test-Input': expected,
651+
}
652+
const urlWithCacheBusting = addCacheBustingSearchParam(
653+
pathname,
654+
headers
655+
)
656+
657+
const res = await next.fetch(urlWithCacheBusting, {
658+
headers,
606659
})
607660
expect(res.status).toEqual(200)
608661
expect(res.headers.get('content-type')).toEqual('text/x-component')
@@ -612,11 +665,17 @@ describe('ppr-full', () => {
612665
} else {
613666
it('should not contain dynamic content', async () => {
614667
const unexpected = `${Date.now()}:${Math.random()}`
615-
const res = await next.fetch(pathname, {
616-
headers: {
617-
RSC: '1',
618-
'X-Test-Input': unexpected,
619-
},
668+
const headers = {
669+
RSC: '1',
670+
'X-Test-Input': unexpected,
671+
}
672+
const urlWithCacheBusting = addCacheBustingSearchParam(
673+
pathname,
674+
headers
675+
)
676+
677+
const res = await next.fetch(urlWithCacheBusting, {
678+
headers,
620679
})
621680
expect(res.status).toEqual(200)
622681
expect(res.headers.get('content-type')).toEqual('text/x-component')

test/e2e/app-dir/rewrite-headers/rewrite-headers.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { nextTestSetup } from 'e2e-utils'
2+
import { computeCacheBustingSearchParam } from 'next/dist/shared/lib/router/utils/cache-busting-search-param'
23

34
const targets = ['x-nextjs-rewritten-path', 'x-nextjs-rewritten-query'] as const
45

@@ -381,7 +382,29 @@ describe('rewrite-headers', () => {
381382
({ pathname, headers = {}, expected }) => {
382383
let response
383384
beforeAll(async () => {
384-
response = await next.fetch(pathname, { headers })
385+
const url = new URL(pathname, 'http://localhost')
386+
387+
// Add cache busting param for RSC requests
388+
if (headers.RSC === '1') {
389+
const cacheBustingParam = computeCacheBustingSearchParam(
390+
headers['Next-Router-Prefetch'],
391+
undefined,
392+
headers['Next-Router-State-Tree'],
393+
undefined
394+
)
395+
if (cacheBustingParam) {
396+
// Preserve existing search params if any
397+
const existingSearch = url.search
398+
const rawQuery = existingSearch.startsWith('?')
399+
? existingSearch.slice(1)
400+
: existingSearch
401+
const pairs = rawQuery.split('&').filter(Boolean)
402+
pairs.push(`_rsc=${cacheBustingParam}`)
403+
url.search = pairs.length ? `?${pairs.join('&')}` : ''
404+
}
405+
}
406+
407+
response = await next.fetch(url.toString(), { headers })
385408
if (response.status !== 200) {
386409
throw new Error(
387410
`Expected status 200, got ${response.status} for ${pathname}`

0 commit comments

Comments
 (0)