Skip to content

Commit 8581b80

Browse files
committed
Check cache busting search params on all RSC requests
1 parent ed567e7 commit 8581b80

File tree

14 files changed

+359
-102
lines changed

14 files changed

+359
-102
lines changed

packages/next/src/client/components/router-reducer/set-cache-busting-search-param.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
NEXT_ROUTER_STATE_TREE_HEADER,
88
NEXT_URL,
99
NEXT_RSC_UNION_QUERY,
10+
RSC_HEADER,
1011
} from '../app-router-headers'
1112
import type { RequestHeaders } from './fetch-server-response'
1213

@@ -34,7 +35,8 @@ export const setCacheBustingSearchParam = (
3435
headers[NEXT_ROUTER_PREFETCH_HEADER],
3536
headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER],
3637
headers[NEXT_ROUTER_STATE_TREE_HEADER],
37-
headers[NEXT_URL]
38+
headers[NEXT_URL],
39+
headers[RSC_HEADER]
3840
)
3941
if (uniqueCacheKey === null) {
4042
// None of our custom request headers are present. We don't need to set a

packages/next/src/server/app-render/action-handler.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
RSC_CONTENT_TYPE_HEADER,
1111
NEXT_ROUTER_STATE_TREE_HEADER,
1212
ACTION_HEADER,
13+
NEXT_ROUTER_PREFETCH_HEADER,
14+
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
15+
NEXT_URL,
1316
} from '../../client/components/app-router-headers'
1417
import {
1518
getAccessFallbackHTTPStatus,
@@ -52,6 +55,7 @@ import type { TemporaryReferenceSet } from 'react-server-dom-webpack/server.edge
5255
import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external'
5356
import { executeRevalidates } from '../revalidation-utils'
5457
import { getRequestMeta } from '../request-meta'
58+
import { setCacheBustingSearchParam } from '../../client/components/router-reducer/set-cache-busting-search-param'
5559

5660
function formDataFromSearchQueryString(query: string) {
5761
const searchParams = new URLSearchParams(query)
@@ -337,6 +341,23 @@ async function createRedirectRenderResult(
337341
forwardedHeaders.delete(ACTION_HEADER)
338342

339343
try {
344+
setCacheBustingSearchParam(fetchUrl, {
345+
[NEXT_ROUTER_PREFETCH_HEADER]: forwardedHeaders.get(
346+
NEXT_ROUTER_PREFETCH_HEADER
347+
)
348+
? ('1' as const)
349+
: undefined,
350+
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]:
351+
forwardedHeaders.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER) ??
352+
undefined,
353+
[NEXT_ROUTER_STATE_TREE_HEADER]:
354+
forwardedHeaders.get(NEXT_ROUTER_STATE_TREE_HEADER) ?? undefined,
355+
[NEXT_URL]: forwardedHeaders.get(NEXT_URL) ?? undefined,
356+
[RSC_HEADER]: forwardedHeaders.get(RSC_HEADER)
357+
? ('1' as const)
358+
: undefined,
359+
})
360+
340361
const response = await fetch(fetchUrl, {
341362
method: 'GET',
342363
headers: forwardedHeaders,

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

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,39 +2055,39 @@ 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(
20822074
headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()],
20832075
headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER.toLowerCase()],
20842076
headers[NEXT_ROUTER_STATE_TREE_HEADER.toLowerCase()],
2085-
headers[NEXT_URL.toLowerCase()]
2077+
headers[NEXT_URL.toLowerCase()],
2078+
isRSCRequest ? '1' : '0'
20862079
)
2087-
const actualHash = getRequestMeta(req, 'cacheBustingSearchParam') ?? null
2080+
const actualHash =
2081+
getRequestMeta(req, 'cacheBustingSearchParam') ??
2082+
new URL(
2083+
req.url || '',
2084+
`http://${req.headers.host || 'localhost'}`
2085+
).searchParams.get(NEXT_RSC_UNION_QUERY)
2086+
20882087
if (expectedHash !== actualHash) {
20892088
// The hash sent by the client does not match the expected value.
20902089
// Respond with an error.
2090+
// TODO: Change it to a redirect to the URL with the correct cache-busting search param.
20912091
res.statusCode = 400
20922092
res.setHeader('content-type', 'text/plain')
20932093
res.body('').send()
@@ -2173,10 +2173,6 @@ export default abstract class Server<
21732173
const isPrefetchRSCRequest =
21742174
getRequestMeta(req, 'isPrefetchRSCRequest') ?? false
21752175

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-
21802176
// when we are handling a middleware prefetch and it doesn't
21812177
// resolve to a static data route we bail early to avoid
21822178
// unexpected SSR invocations

packages/next/src/shared/lib/router/utils/cache-busting-search-param.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ export function computeCacheBustingSearchParam(
44
prefetchHeader: string | string[] | undefined,
55
segmentPrefetchHeader: string | string[] | undefined,
66
stateTreeHeader: string | string[] | undefined,
7-
nextUrlHeader: string | string[] | undefined
7+
nextUrlHeader: string | string[] | undefined,
8+
rscHeader: string | string[] | undefined
89
): string | null {
910
if (
1011
prefetchHeader === undefined &&
1112
segmentPrefetchHeader === undefined &&
1213
stateTreeHeader === undefined &&
13-
nextUrlHeader === undefined
14+
nextUrlHeader === undefined &&
15+
rscHeader === undefined
1416
) {
1517
return null
1618
}
@@ -20,6 +22,7 @@ export function computeCacheBustingSearchParam(
2022
segmentPrefetchHeader || '0',
2123
stateTreeHeader || '0',
2224
nextUrlHeader || '0',
25+
rscHeader || '0', // This ensures there is always a cache-busting search param for RSC requests even when no other headers are present
2326
].join(',')
2427
)
2528
}

test/e2e/app-dir/app-inline-css/index.test.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { nextTestSetup } from 'e2e-utils'
2+
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
3+
import { computeCacheBustingSearchParam } from 'next/dist/shared/lib/router/utils/cache-busting-search-param'
4+
25
describe('app dir - css - experimental inline css', () => {
36
const { next, isNextDev } = nextTestSetup({
47
files: __dirname,
@@ -16,13 +19,24 @@ describe('app dir - css - experimental inline css', () => {
1619
})
1720

1821
it('should not return rsc payload with inlined style as a dynamic client nav', async () => {
22+
const headers = {
23+
rsc: '1',
24+
}
25+
const cacheBustingSearchParam = computeCacheBustingSearchParam(
26+
null,
27+
null,
28+
null,
29+
null,
30+
'1'
31+
)
1932
const rscPayload = await (
20-
await next.fetch('/a', {
21-
method: 'GET',
22-
headers: {
23-
rsc: '1',
24-
},
25-
})
33+
await next.fetch(
34+
`/a?${NEXT_RSC_UNION_QUERY}=${cacheBustingSearchParam}`,
35+
{
36+
method: 'GET',
37+
headers,
38+
}
39+
)
2640
).text()
2741

2842
const style = 'font-size'
@@ -32,9 +46,12 @@ describe('app dir - css - experimental inline css', () => {
3246

3347
expect(
3448
await (
35-
await next.fetch('/a', {
36-
method: 'GET',
37-
})
49+
await next.fetch(
50+
`/a?${NEXT_RSC_UNION_QUERY}=${cacheBustingSearchParam}`,
51+
{
52+
method: 'GET',
53+
}
54+
)
3855
).text()
3956
).toContain(style) // sanity check that HTML has the style
4057
})

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

Lines changed: 22 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,27 @@ 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+
headers['RSC']
313+
)
314+
if (cacheBustingParam) {
315+
url.searchParams.set('_rsc', cacheBustingParam)
316+
}
317+
318+
const response = await next.fetch(url.toString(), { headers })
305319

306320
const prefetchResponse = await response.text()
307321
expect(prefetchResponse).not.toContain('Page Data!')
Lines changed: 42 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,49 @@ 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+
'1'
38+
)
39+
const cacheBustingParam2 = computeCacheBustingSearchParam(
40+
undefined,
41+
undefined,
42+
stateTree2,
43+
undefined,
44+
'1'
45+
)
46+
47+
if (cacheBustingParam1) {
48+
url1.searchParams.set('_rsc', cacheBustingParam1)
49+
}
50+
if (cacheBustingParam2) {
51+
url2.searchParams.set('_rsc', cacheBustingParam2)
52+
}
53+
54+
const res = await next.fetch(url1.toString(), { headers: headers1 })
2055
expect(res.status).toBe(500)
2156

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

0 commit comments

Comments
 (0)