Skip to content

Commit 5eb21da

Browse files
authored
fix(security): validate supplemental interception selectors (#2976)
* fix(security): verify interception cache identities * fix(security): bypass caches for interception selectors * fix(security): preserve caching for verified interception selectors * fix(cache): delegate selector policy to CDN adapter
1 parent 2dcd9f2 commit 5eb21da

15 files changed

Lines changed: 523 additions & 40 deletions

packages/vinext/src/entries/app-rsc-entry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,10 @@ function findIntercept(pathname, sourcePathname = null, interceptionId = null) {
642642
return __routeMatcher.findIntercept(pathname, sourcePathname, interceptionId);
643643
}
644644
645+
function hasInterceptionId(interceptionId) {
646+
return __routeMatcher.hasInterceptionId(interceptionId);
647+
}
648+
645649
async function buildPageElements(route, params, routePath, pageRequest, layoutParamAccess, displayPathname = routePath, scriptNonce) {
646650
// Hydrate lazy page/route-handler modules before any synchronous read.
647651
await __ensureRouteLoaded(route);
@@ -1271,6 +1275,7 @@ export default createAppRscHandler({
12711275
}
12721276
matchRoute,
12731277
matchRequestRoute,
1278+
hasInterceptionId,
12741279
matchInterceptRoute(pathname, sourcePathname, interceptionId) {
12751280
const intercept = findIntercept(pathname, sourcePathname, interceptionId);
12761281
if (!intercept) return null;

packages/vinext/src/server/app-page-cache-finalizer.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type AppPageRscCacheKeyBuilder = (
1919
mountedSlotsHeader?: string | null,
2020
renderMode?: AppRscRenderMode,
2121
interceptionContext?: string | null,
22+
interceptionId?: string | null,
2223
) => string;
2324
type AppPageRequestCacheLife = {
2425
revalidate?: number;
@@ -45,6 +46,7 @@ type FinalizeAppPageHtmlCacheResponseOptions = {
4546
isrRscKey: AppPageRscCacheKeyBuilder;
4647
isrSet: AppPageCacheSetter;
4748
interceptionContext?: string | null;
49+
interceptionId?: string | null;
4850
omitPendingDynamicCacheState?: boolean;
4951
preserveClientResponseHeaders?: boolean;
5052
expireSeconds?: number;
@@ -66,6 +68,7 @@ type ScheduleAppPageRscCacheWriteOptions = {
6668
isrRscKey: AppPageRscCacheKeyBuilder;
6769
isrSet: AppPageCacheSetter;
6870
interceptionContext?: string | null;
71+
interceptionId?: string | null;
6972
mountedSlotsHeader?: string | null;
7073
omitPendingDynamicCacheState?: boolean;
7174
renderMode?: AppRscRenderMode;
@@ -85,17 +88,15 @@ function applyPendingDynamicCdnHeaders(
8588
finalizePendingCacheStateHeaders(headers, options);
8689
}
8790

88-
function applyMountedSlotRscNoStoreHeaders(
91+
function applyUncacheableRscVariantNoStoreHeaders(
8992
headers: Headers,
9093
options: { omitCacheState?: boolean } = {},
9194
): void {
9295
// Mounted-slot RSC payloads deliberately bypass the slot-blind persistent
93-
// cache. Make that same bypass explicit to every CDN adapter: an edge-managed
94-
// adapter may intentionally cache pending-dynamic responses, so the generic
95-
// pendingDynamicCheck signal is not strong enough for this variant.
96-
// This branch additionally forces no-store because mounted variants have no
97-
// persistent admission path at all. The active adapter clears any stale
98-
// provider-specific headers that it owns.
96+
// cache. Make that same bypass explicit to
97+
// every CDN adapter: an edge-managed adapter may intentionally cache
98+
// pending-dynamic responses, so that generic signal is not strong enough.
99+
// The active adapter clears any stale provider-specific headers that it owns.
99100
applyCdnResponseHeaders(headers, { cacheControl: NO_STORE_CACHE_CONTROL });
100101
// Dynamic and draft responses intentionally have no cache state. Do not
101102
// manufacture a MISS solely because the request carried mounted slots.
@@ -169,6 +170,7 @@ export function finalizeAppPageHtmlCacheResponse(
169170
null,
170171
undefined,
171172
options.interceptionContext,
173+
options.interceptionId,
172174
);
173175
const clientHeaders = new Headers(response.headers);
174176
if (options.preserveClientResponseHeaders !== true) {
@@ -266,14 +268,14 @@ export function finalizeAppPageRscCacheResponse(
266268
// slots because edge-managed adapters may cache pending-dynamic responses.
267269
scheduleAppPageRscCacheWrite(options);
268270

269-
const isMountedSlotVariant = Boolean(options.mountedSlotsHeader);
270-
if (options.preserveClientResponseHeaders === true && !isMountedSlotVariant) {
271+
const isUncacheableVariant = Boolean(options.mountedSlotsHeader);
272+
if (options.preserveClientResponseHeaders === true && !isUncacheableVariant) {
271273
return response;
272274
}
273275

274276
const clientHeaders = new Headers(response.headers);
275-
if (isMountedSlotVariant) {
276-
applyMountedSlotRscNoStoreHeaders(clientHeaders, {
277+
if (isUncacheableVariant) {
278+
applyUncacheableRscVariantNoStoreHeaders(clientHeaders, {
277279
omitCacheState: options.omitPendingDynamicCacheState === true,
278280
});
279281
} else {
@@ -302,6 +304,7 @@ export function scheduleAppPageRscCacheWrite(
302304
null,
303305
options.renderMode,
304306
options.interceptionContext,
307+
options.interceptionId,
305308
);
306309
const cachePromise = (async () => {
307310
try {

packages/vinext/src/server/app-page-cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type AppPageRscCacheKeyBuilder = (
3838
mountedSlotsHeader?: string | null,
3939
renderMode?: AppRscRenderMode,
4040
interceptionContext?: string | null,
41+
interceptionId?: string | null,
4142
) => string;
4243
export type AppPageCacheOutcomeMetric = Readonly<{
4344
artifact: "html" | "rsc";
@@ -92,6 +93,7 @@ type ReadAppPageCacheResponseOptions = {
9293
isrRscKey: AppPageRscCacheKeyBuilder;
9394
isrSet: AppPageCacheSetter;
9495
interceptionContext?: string | null;
96+
interceptionId?: string | null;
9597
hasRequestSearchParams?: boolean;
9698
middlewareHeaders?: Headers | null;
9799
middlewareStatus?: number | null;
@@ -370,6 +372,7 @@ export async function readAppPageCacheResponse(
370372
null,
371373
options.renderMode,
372374
options.interceptionContext,
375+
options.interceptionId,
373376
)
374377
: options.isrHtmlKey(options.cleanPathname);
375378
const artifact = options.isRscRequest ? "rsc" : "html";
@@ -479,6 +482,7 @@ export async function readAppPageCacheResponse(
479482
null,
480483
options.renderMode,
481484
options.interceptionContext,
485+
options.interceptionId,
482486
),
483487
buildAppPageCacheValue(
484488
"",

packages/vinext/src/server/app-page-dispatch.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ import {
8282
import { shouldServeStreamingMetadata } from "./streaming-metadata.js";
8383
import { createAppPageTreePath } from "./app-page-route-wiring.js";
8484
import type { AppPageSsrHandler } from "./app-page-stream.js";
85-
import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js";
85+
import { VINEXT_INTERCEPTION_ID_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js";
8686
import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js";
8787
import { buildAppPageTags } from "./implicit-tags.js";
8888
import type { AppPageCacheSetter, ISRCacheEntry } from "./isr-cache.js";
@@ -342,6 +342,7 @@ export type DispatchAppPageOptions<TRoute extends AppPageDispatchRoute> = {
342342
mountedSlotsHeader?: string | null,
343343
renderMode?: AppRscRenderMode,
344344
interceptionContext?: string | null,
345+
interceptionId?: string | null,
345346
) => string;
346347
isrSet: AppPageCacheSetter;
347348
loadSsrHandler: () => Promise<AppPageSsrHandler>;
@@ -627,6 +628,9 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
627628
const route = options.route;
628629
const dynamicConfig = options.dynamicConfig;
629630
const currentRevalidateSeconds = options.revalidateSeconds;
631+
const interceptionId = options.isRscRequest
632+
? options.request.headers.get(VINEXT_INTERCEPTION_ID_HEADER)
633+
: null;
630634
const isForceStatic = dynamicConfig === "force-static";
631635
const isDynamicError = dynamicConfig === "error";
632636
const isForceDynamic = dynamicConfig === "force-dynamic";
@@ -723,6 +727,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
723727
isrRscKey: options.isrRscKey,
724728
isrSet: options.isrSet,
725729
interceptionContext: options.interceptionContext,
730+
interceptionId,
726731
middlewareHeaders: options.middlewareContext.headers,
727732
middlewareStatus: options.middlewareContext.status,
728733
mountedSlotsHeader: options.mountedSlotsHeader,
@@ -1137,6 +1142,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
11371142
isrRscKey: options.isrRscKey,
11381143
isrSet: options.isrSet,
11391144
interceptionContext: options.interceptionContext,
1145+
interceptionId,
11401146
expireSeconds: options.expireSeconds,
11411147
// A loading convention at tree position N wraps descendants, but not a
11421148
// layout co-located at N. Probing any deeper async layout before creating

packages/vinext/src/server/app-page-render.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,11 @@ type RenderAppPageLifecycleOptions = {
151151
mountedSlotsHeader?: string | null,
152152
renderMode?: AppRscRenderMode,
153153
interceptionContext?: string | null,
154+
interceptionId?: string | null,
154155
) => string;
155156
isrSet: AppPageCacheSetter;
156157
interceptionContext?: string | null;
158+
interceptionId?: string | null;
157159
layoutCount: number;
158160
loadSsrHandler: () => Promise<AppPageSsrHandler>;
159161
middlewareContext: AppPageMiddlewareContext;
@@ -820,7 +822,8 @@ export async function renderAppPageLifecycle(
820822
// When skip transport is enabled, omit cacheState because the response is a
821823
// per-client payload, not a shared-cache MISS/HIT artifact. The absence also
822824
// keeps finalizeAppPageRscCacheResponse from overwriting no-store.
823-
const rscResponsePolicy = shouldBypassRscCacheForSkipTransport
825+
const shouldBypassRscCache = shouldBypassRscCacheForSkipTransport;
826+
const rscResponsePolicy = shouldBypassRscCache
824827
? { cacheControl: NO_STORE_CACHE_CONTROL }
825828
: resolveAppPageRscResponsePolicy({
826829
dynamicUsedDuringBuild,
@@ -832,7 +835,7 @@ export async function renderAppPageLifecycle(
832835
expireSeconds,
833836
revalidateSeconds,
834837
});
835-
if (shouldBypassRscCacheForSkipTransport) {
838+
if (shouldBypassRscCache) {
836839
options.isrDebug?.("RSC cache write skipped (skip transport payload)", options.cleanPathname);
837840
}
838841
const shouldEmitDynamicStaleTime =
@@ -955,6 +958,7 @@ export async function renderAppPageLifecycle(
955958
isrRscKey: options.isrRscKey,
956959
isrSet: options.isrSet,
957960
interceptionContext: options.interceptionContext,
961+
interceptionId: options.interceptionId,
958962
mountedSlotsHeader: options.mountedSlotsHeader,
959963
omitPendingDynamicCacheState: options.omitPendingDynamicCacheState,
960964
renderMode: options.renderMode,
@@ -1268,6 +1272,7 @@ export async function renderAppPageLifecycle(
12681272
isrRscKey: options.isrRscKey,
12691273
isrSet: options.isrSet,
12701274
interceptionContext: options.interceptionContext,
1275+
interceptionId: options.interceptionId,
12711276
omitPendingDynamicCacheState: options.omitPendingDynamicCacheState,
12721277
preserveClientResponseHeaders: !htmlResponsePolicy.shouldWriteToCache,
12731278
expireSeconds,

packages/vinext/src/server/app-rsc-cache-busting.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -417,39 +417,51 @@ export async function resolveInvalidRscCacheBustingRequest(
417417
if (actualHash !== null && actualHash !== expectedHash) {
418418
acceptedHashes.add(computeLegacyRscCacheBustingSearchParam(options.request.headers));
419419
const compatibilityInputs: CreateCacheBustingInputOptions[] = [];
420+
const hasInterceptionId = options.request.headers.has(VINEXT_INTERCEPTION_ID_HEADER);
420421
const hasStateFingerprint = options.request.headers.has(VINEXT_RSC_STATE_FINGERPRINT_HEADER);
421422
const hasNormalRenderMode =
422423
normalizeRenderModeHeaderValue(options.request.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)) ===
423424
null;
424425
// The interception ID is a positional hash input, so omitting it changes
425-
// hashes even when the request does not carry the header. Accept every
426-
// combination of the rollout-era inputs to keep in-flight RSC requests
427-
// valid while old and new clients overlap.
428-
compatibilityInputs.push({ includeInterceptionIdHeader: false });
426+
// hashes even when the request does not carry the header. Requests from
427+
// clients predating that positional input remain compatible when the
428+
// header is absent. Once the header is present, however, only hashes that
429+
// include its value are safe: Cloudflare's default cache key is URL-based
430+
// and does not vary on arbitrary headers, while different graph-owned IDs
431+
// can intentionally select different slot bytes for one source/target.
432+
if (!hasInterceptionId) {
433+
compatibilityInputs.push({ includeInterceptionIdHeader: false });
434+
}
429435
if (hasStateFingerprint) {
430436
compatibilityInputs.push({ includeStateFingerprintHeader: false });
431-
compatibilityInputs.push({
432-
includeInterceptionIdHeader: false,
433-
includeStateFingerprintHeader: false,
434-
});
437+
if (!hasInterceptionId) {
438+
compatibilityInputs.push({
439+
includeInterceptionIdHeader: false,
440+
includeStateFingerprintHeader: false,
441+
});
442+
}
435443
}
436444
if (hasNormalRenderMode) {
437445
compatibilityInputs.push({ includeRenderModeHeader: false });
438-
compatibilityInputs.push({
439-
includeInterceptionIdHeader: false,
440-
includeRenderModeHeader: false,
441-
});
446+
if (!hasInterceptionId) {
447+
compatibilityInputs.push({
448+
includeInterceptionIdHeader: false,
449+
includeRenderModeHeader: false,
450+
});
451+
}
442452
}
443453
if (hasStateFingerprint && hasNormalRenderMode) {
444454
compatibilityInputs.push({
445455
includeRenderModeHeader: false,
446456
includeStateFingerprintHeader: false,
447457
});
448-
compatibilityInputs.push({
449-
includeInterceptionIdHeader: false,
450-
includeRenderModeHeader: false,
451-
includeStateFingerprintHeader: false,
452-
});
458+
if (!hasInterceptionId) {
459+
compatibilityInputs.push({
460+
includeInterceptionIdHeader: false,
461+
includeRenderModeHeader: false,
462+
includeStateFingerprintHeader: false,
463+
});
464+
}
453465
}
454466
for (const compatibilityOptions of compatibilityInputs) {
455467
const input = createCacheBustingInput(options.request.headers, compatibilityOptions);

0 commit comments

Comments
 (0)