Skip to content

Commit 70cddda

Browse files
authored
fix: prevent CloudFront cache poisoning for Next.js RSC responses (#119)
## Summary Prevent CloudFront cache poisoning between HTML and RSC flight responses by adding a CloudFront Function that hashes Next.js RSC headers into a single cache key header. Closes #100 Supersedes #118 ## Problem Next.js App Router sends two types of requests to the same URL: 1. **HTML requests** — full page loads 2. **RSC requests** — client-side navigation with `RSC: 1` header, returning `text/x-component` flight data Next.js sets `Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch` (plus `next-url` for interception routes) to signal that responses differ based on these headers. However, CloudFront does not honor `Vary` — headers must be explicitly included in the Cache Policy to become part of the cache key. Without this fix, when CloudFront caching is active (static pages, ISR, or explicit cache headers), an RSC response can be cached and served for a normal HTML request, or vice versa. ## Approach Adding all 5 RSC headers directly to the Cache Policy would hit CloudFront's **10-header limit** (5 existing + 5 = 10, no room for future additions). Instead, we use a **CloudFront Function** (VIEWER_REQUEST) that: 1. Reads the 5 Next.js RSC headers (`rsc`, `next-router-prefetch`, `next-router-state-tree`, `next-router-segment-prefetch`, `next-url`) 2. Hashes them into a single `x-nextjs-cache-key` header using FNV-1a 3. The Cache Policy includes only `x-nextjs-cache-key` (6/10 headers used) This is the same approach used by [cdk-nextjs](https://github.com/jetbridge/cdk-nextjs) (which hashes into `x-open-next-cache-key`). ### Why CloudFront Function (not Lambda@Edge)? The existing `sign-payload` Lambda@Edge (ORIGIN_REQUEST) handles request body hashing for SigV4, which requires body access — only possible with Lambda@Edge. The RSC header hashing is a lightweight header-only operation ideal for CloudFront Functions. Both coexist on the same behavior (CF Function at VIEWER_REQUEST, L@E at ORIGIN_REQUEST). ## Files changed - `cdk/lib/constructs/cf-lambda-furl-service/cf-function/cache-key.js` — New CloudFront Function - `cdk/lib/constructs/cf-lambda-furl-service/service.ts` — Wire up CF Function + add `x-nextjs-cache-key` to Cache Policy ## Grounding / References - **Next.js source (v16.1.6)** `base-server.js:setVaryHeader()` — Confirms `Vary` includes `rsc`, `next-router-state-tree`, `next-router-prefetch`, `next-router-segment-prefetch` for all App Router pages, plus `next-url` for interception routes - **Next.js source** `app-render.js:149` — `next-router-segment-prefetch: /_tree` triggers a different response (route tree only), confirming it must be in the cache key - **CVE-2025-49005** ([Vercel](https://vercel.com/changelog/cve-2025-49005), [GHSA-r2fc-ccr8-96c4](GHSA-r2fc-ccr8-96c4)) — Cache poisoning via missing `Vary` header in Next.js 15.3.0–15.3.3. Workaround: manually set `Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch` - **[Running Next.js behind AWS CloudFront](https://www.bstefanski.com/blog/running-nextjs-behind-aws-cloudfront)** — Documents the same cache poisoning issue and fix - **[cdk-nextjs](https://github.com/jetbridge/cdk-nextjs)** `NextjsDistribution.ts` — Uses the same hash-into-single-header approach with `x-open-next-cache-key` - **[CloudFront quotas](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html)** — Cache Policy allows max 10 headers
1 parent 87abeb9 commit 70cddda

File tree

5 files changed

+188
-0
lines changed

5 files changed

+188
-0
lines changed

cdk/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
*.js
22
!jest.config.js
3+
!lib/constructs/cf-lambda-furl-service/cf-function/*.js
34
*.d.ts
45
node_modules
56

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// CloudFront Functions JS 2.0
2+
// Combines Next.js RSC-related headers into a single hashed cache key header
3+
// to prevent cache poisoning between HTML and RSC flight responses.
4+
//
5+
// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch,
6+
// next-router-segment-prefetch (and next-url for interception routes).
7+
// CloudFront ignores Vary and requires explicit cache key configuration,
8+
// but its Cache Policy has a 10-header limit. This function hashes all
9+
// RSC headers into one header to stay within the limit.
10+
async function handler(event) {
11+
var h = event.request.headers;
12+
var parts = [
13+
'rsc',
14+
'next-router-prefetch',
15+
'next-router-state-tree',
16+
'next-router-segment-prefetch',
17+
'next-url',
18+
];
19+
var key = '';
20+
for (var i = 0; i < parts.length; i++) {
21+
if (h[parts[i]]) {
22+
key += parts[i] + '=' + h[parts[i]].value + ';';
23+
}
24+
}
25+
if (key) {
26+
// FNV-1a hash (32-bit). Cryptographic strength is unnecessary;
27+
// we only need distinct cache keys for distinct header combinations.
28+
// See: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
29+
var FNV_OFFSET_BASIS = 2166136261;
30+
var FNV_PRIME = 16777619;
31+
var hash = FNV_OFFSET_BASIS;
32+
for (var j = 0; j < key.length; j++) {
33+
hash ^= key.charCodeAt(j);
34+
hash = (hash * FNV_PRIME) | 0;
35+
}
36+
event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) };
37+
}
38+
return event.request;
39+
}

cdk/lib/constructs/cf-lambda-furl-service/service.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ import {
88
CachePolicy,
99
CacheQueryStringBehavior,
1010
Distribution,
11+
Function as CfFunction,
12+
FunctionCode as CfFunctionCode,
13+
FunctionEventType,
14+
FunctionRuntime,
1115
LambdaEdgeEventType,
1216
OriginRequestPolicy,
1317
SecurityPolicyProtocol,
1418
} from 'aws-cdk-lib/aws-cloudfront';
1519
import { FunctionUrlOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
20+
import * as path from 'path';
1621
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
1722
import { ARecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53';
1823
import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets';
@@ -89,20 +94,37 @@ export class CloudFrontLambdaFunctionUrlService extends Construct {
8994
'X-HTTP-Method-Override',
9095
'X-HTTP-Method',
9196
'X-Method-Override',
97+
// Hashed Next.js RSC headers set by the CloudFront Function below.
98+
// See cf-function/cache-key.js for details.
99+
'x-nextjs-cache-key',
92100
),
93101
defaultTtl: Duration.seconds(0),
94102
cookieBehavior: CacheCookieBehavior.all(),
95103
enableAcceptEncodingBrotli: true,
96104
enableAcceptEncodingGzip: true,
97105
});
98106

107+
// CloudFront Function to hash Next.js RSC headers into a single cache key header.
108+
// This prevents cache poisoning between HTML and RSC flight responses while
109+
// staying within CloudFront's 10-header limit on Cache Policies.
110+
const cacheKeyFunction = new CfFunction(this, 'CacheKeyFunction', {
111+
runtime: FunctionRuntime.JS_2_0,
112+
code: CfFunctionCode.fromFile({ filePath: path.join(__dirname, 'cf-function', 'cache-key.js') }),
113+
});
114+
99115
const distribution = new Distribution(this, 'Resource', {
100116
comment: `CloudFront for ${serviceName}`,
101117
defaultBehavior: {
102118
origin,
103119
cachePolicy,
104120
allowedMethods: AllowedMethods.ALLOW_ALL,
105121
originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
122+
functionAssociations: [
123+
{
124+
function: cacheKeyFunction,
125+
eventType: FunctionEventType.VIEWER_REQUEST,
126+
},
127+
],
106128
edgeLambdas: [
107129
{
108130
functionVersion: signPayloadHandler.versionArn(this),

cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit-without-domain.test.ts.snap

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3087,6 +3087,17 @@ service iptables save",
30873087
"Ref": "WebappSharedCachePolicy14FEE4A0",
30883088
},
30893089
"Compress": true,
3090+
"FunctionAssociations": [
3091+
{
3092+
"EventType": "viewer-request",
3093+
"FunctionARN": {
3094+
"Fn::GetAtt": [
3095+
"WebappCacheKeyFunction6C227CE2",
3096+
"FunctionARN",
3097+
],
3098+
},
3099+
},
3100+
],
30903101
"LambdaFunctionAssociations": [
30913102
{
30923103
"EventType": "origin-request",
@@ -3307,6 +3318,57 @@ service iptables save",
33073318
"Type": "AWS::ECR::Repository",
33083319
"UpdateReplacePolicy": "Delete",
33093320
},
3321+
"WebappCacheKeyFunction6C227CE2": {
3322+
"Properties": {
3323+
"AutoPublish": true,
3324+
"FunctionCode": "// CloudFront Functions JS 2.0
3325+
// Combines Next.js RSC-related headers into a single hashed cache key header
3326+
// to prevent cache poisoning between HTML and RSC flight responses.
3327+
//
3328+
// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch,
3329+
// next-router-segment-prefetch (and next-url for interception routes).
3330+
// CloudFront ignores Vary and requires explicit cache key configuration,
3331+
// but its Cache Policy has a 10-header limit. This function hashes all
3332+
// RSC headers into one header to stay within the limit.
3333+
async function handler(event) {
3334+
var h = event.request.headers;
3335+
var parts = [
3336+
'rsc',
3337+
'next-router-prefetch',
3338+
'next-router-state-tree',
3339+
'next-router-segment-prefetch',
3340+
'next-url',
3341+
];
3342+
var key = '';
3343+
for (var i = 0; i < parts.length; i++) {
3344+
if (h[parts[i]]) {
3345+
key += parts[i] + '=' + h[parts[i]].value + ';';
3346+
}
3347+
}
3348+
if (key) {
3349+
// FNV-1a hash (32-bit). Cryptographic strength is unnecessary;
3350+
// we only need distinct cache keys for distinct header combinations.
3351+
// See: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
3352+
var FNV_OFFSET_BASIS = 2166136261;
3353+
var FNV_PRIME = 16777619;
3354+
var hash = FNV_OFFSET_BASIS;
3355+
for (var j = 0; j < key.length; j++) {
3356+
hash ^= key.charCodeAt(j);
3357+
hash = (hash * FNV_PRIME) | 0;
3358+
}
3359+
event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) };
3360+
}
3361+
return event.request;
3362+
}
3363+
",
3364+
"FunctionConfig": {
3365+
"Comment": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3366+
"Runtime": "cloudfront-js-2.0",
3367+
},
3368+
"Name": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3369+
},
3370+
"Type": "AWS::CloudFront::Function",
3371+
},
33103372
"WebappCloudFrontInvalidation588CF152": {
33113373
"DeletionPolicy": "Delete",
33123374
"DependsOn": [
@@ -4202,6 +4264,7 @@ service iptables save",
42024264
"X-HTTP-Method-Override",
42034265
"X-HTTP-Method",
42044266
"X-Method-Override",
4267+
"x-nextjs-cache-key",
42054268
],
42064269
},
42074270
"QueryStringsConfig": {

cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit.test.ts.snap

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2922,6 +2922,17 @@ service iptables save",
29222922
"Ref": "WebappSharedCachePolicy14FEE4A0",
29232923
},
29242924
"Compress": true,
2925+
"FunctionAssociations": [
2926+
{
2927+
"EventType": "viewer-request",
2928+
"FunctionARN": {
2929+
"Fn::GetAtt": [
2930+
"WebappCacheKeyFunction6C227CE2",
2931+
"FunctionARN",
2932+
],
2933+
},
2934+
},
2935+
],
29252936
"LambdaFunctionAssociations": [
29262937
{
29272938
"EventType": "origin-request",
@@ -3152,6 +3163,57 @@ service iptables save",
31523163
"Type": "AWS::ECR::Repository",
31533164
"UpdateReplacePolicy": "Delete",
31543165
},
3166+
"WebappCacheKeyFunction6C227CE2": {
3167+
"Properties": {
3168+
"AutoPublish": true,
3169+
"FunctionCode": "// CloudFront Functions JS 2.0
3170+
// Combines Next.js RSC-related headers into a single hashed cache key header
3171+
// to prevent cache poisoning between HTML and RSC flight responses.
3172+
//
3173+
// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch,
3174+
// next-router-segment-prefetch (and next-url for interception routes).
3175+
// CloudFront ignores Vary and requires explicit cache key configuration,
3176+
// but its Cache Policy has a 10-header limit. This function hashes all
3177+
// RSC headers into one header to stay within the limit.
3178+
async function handler(event) {
3179+
var h = event.request.headers;
3180+
var parts = [
3181+
'rsc',
3182+
'next-router-prefetch',
3183+
'next-router-state-tree',
3184+
'next-router-segment-prefetch',
3185+
'next-url',
3186+
];
3187+
var key = '';
3188+
for (var i = 0; i < parts.length; i++) {
3189+
if (h[parts[i]]) {
3190+
key += parts[i] + '=' + h[parts[i]].value + ';';
3191+
}
3192+
}
3193+
if (key) {
3194+
// FNV-1a hash (32-bit). Cryptographic strength is unnecessary;
3195+
// we only need distinct cache keys for distinct header combinations.
3196+
// See: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
3197+
var FNV_OFFSET_BASIS = 2166136261;
3198+
var FNV_PRIME = 16777619;
3199+
var hash = FNV_OFFSET_BASIS;
3200+
for (var j = 0; j < key.length; j++) {
3201+
hash ^= key.charCodeAt(j);
3202+
hash = (hash * FNV_PRIME) | 0;
3203+
}
3204+
event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) };
3205+
}
3206+
return event.request;
3207+
}
3208+
",
3209+
"FunctionConfig": {
3210+
"Comment": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3211+
"Runtime": "cloudfront-js-2.0",
3212+
},
3213+
"Name": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3214+
},
3215+
"Type": "AWS::CloudFront::Function",
3216+
},
31553217
"WebappCloudFrontInvalidation588CF152": {
31563218
"DeletionPolicy": "Delete",
31573219
"DependsOn": [
@@ -4026,6 +4088,7 @@ service iptables save",
40264088
"X-HTTP-Method-Override",
40274089
"X-HTTP-Method",
40284090
"X-Method-Override",
4091+
"x-nextjs-cache-key",
40294092
],
40304093
},
40314094
"QueryStringsConfig": {

0 commit comments

Comments
 (0)