Skip to content

Commit 3b6da4f

Browse files
committed
fix: prevent CloudFront cache poisoning for Next.js RSC responses
Add a CloudFront Function (VIEWER_REQUEST) that hashes Next.js RSC headers (rsc, next-router-prefetch, next-router-state-tree, next-router-segment-prefetch, next-url) into a single x-nextjs-cache-key header included in the Cache Policy. This approach avoids CloudFront's 10-header limit on Cache Policies (currently 5 used + 1 added = 6/10) while correctly separating cache entries for HTML vs RSC flight responses. Closes #100
1 parent 764a4fa commit 3b6da4f

File tree

5 files changed

+182
-0
lines changed

5 files changed

+182
-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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2980,6 +2980,17 @@ service iptables save",
29802980
"Ref": "WebappSharedCachePolicy14FEE4A0",
29812981
},
29822982
"Compress": true,
2983+
"FunctionAssociations": [
2984+
{
2985+
"EventType": "viewer-request",
2986+
"FunctionARN": {
2987+
"Fn::GetAtt": [
2988+
"WebappCacheKeyFunction6C227CE2",
2989+
"FunctionARN",
2990+
],
2991+
},
2992+
},
2993+
],
29832994
"LambdaFunctionAssociations": [
29842995
{
29852996
"EventType": "origin-request",
@@ -3200,6 +3211,54 @@ service iptables save",
32003211
"Type": "AWS::ECR::Repository",
32013212
"UpdateReplacePolicy": "Delete",
32023213
},
3214+
"WebappCacheKeyFunction6C227CE2": {
3215+
"Properties": {
3216+
"AutoPublish": true,
3217+
"FunctionCode": "// CloudFront Functions JS 2.0
3218+
// Combines Next.js RSC-related headers into a single hashed cache key header
3219+
// to prevent cache poisoning between HTML and RSC flight responses.
3220+
//
3221+
// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch,
3222+
// next-router-segment-prefetch (and next-url for interception routes).
3223+
// CloudFront ignores Vary and requires explicit cache key configuration,
3224+
// but its Cache Policy has a 10-header limit. This function hashes all
3225+
// RSC headers into one header to stay within the limit.
3226+
async function handler(event) {
3227+
var h = event.request.headers;
3228+
var parts = [
3229+
'rsc',
3230+
'next-router-prefetch',
3231+
'next-router-state-tree',
3232+
'next-router-segment-prefetch',
3233+
'next-url',
3234+
];
3235+
var key = '';
3236+
for (var i = 0; i < parts.length; i++) {
3237+
if (h[parts[i]]) {
3238+
key += parts[i] + '=' + h[parts[i]].value + ';';
3239+
}
3240+
}
3241+
if (key) {
3242+
// FNV-1a hash. Cryptographic strength is unnecessary;
3243+
// we only need distinct cache keys for distinct header combinations.
3244+
var hash = 2166136261;
3245+
for (var j = 0; j < key.length; j++) {
3246+
hash ^= key.charCodeAt(j);
3247+
hash = (hash * 16777619) | 0;
3248+
}
3249+
event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) };
3250+
}
3251+
return event.request;
3252+
}
3253+
",
3254+
"FunctionConfig": {
3255+
"Comment": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3256+
"Runtime": "cloudfront-js-2.0",
3257+
},
3258+
"Name": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3259+
},
3260+
"Type": "AWS::CloudFront::Function",
3261+
},
32033262
"WebappCloudFrontInvalidation588CF152": {
32043263
"DeletionPolicy": "Delete",
32053264
"DependsOn": [
@@ -4094,6 +4153,7 @@ service iptables save",
40944153
"X-HTTP-Method-Override",
40954154
"X-HTTP-Method",
40964155
"X-Method-Override",
4156+
"x-nextjs-cache-key",
40974157
],
40984158
},
40994159
"QueryStringsConfig": {

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2815,6 +2815,17 @@ service iptables save",
28152815
"Ref": "WebappSharedCachePolicy14FEE4A0",
28162816
},
28172817
"Compress": true,
2818+
"FunctionAssociations": [
2819+
{
2820+
"EventType": "viewer-request",
2821+
"FunctionARN": {
2822+
"Fn::GetAtt": [
2823+
"WebappCacheKeyFunction6C227CE2",
2824+
"FunctionARN",
2825+
],
2826+
},
2827+
},
2828+
],
28182829
"LambdaFunctionAssociations": [
28192830
{
28202831
"EventType": "origin-request",
@@ -3045,6 +3056,54 @@ service iptables save",
30453056
"Type": "AWS::ECR::Repository",
30463057
"UpdateReplacePolicy": "Delete",
30473058
},
3059+
"WebappCacheKeyFunction6C227CE2": {
3060+
"Properties": {
3061+
"AutoPublish": true,
3062+
"FunctionCode": "// CloudFront Functions JS 2.0
3063+
// Combines Next.js RSC-related headers into a single hashed cache key header
3064+
// to prevent cache poisoning between HTML and RSC flight responses.
3065+
//
3066+
// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch,
3067+
// next-router-segment-prefetch (and next-url for interception routes).
3068+
// CloudFront ignores Vary and requires explicit cache key configuration,
3069+
// but its Cache Policy has a 10-header limit. This function hashes all
3070+
// RSC headers into one header to stay within the limit.
3071+
async function handler(event) {
3072+
var h = event.request.headers;
3073+
var parts = [
3074+
'rsc',
3075+
'next-router-prefetch',
3076+
'next-router-state-tree',
3077+
'next-router-segment-prefetch',
3078+
'next-url',
3079+
];
3080+
var key = '';
3081+
for (var i = 0; i < parts.length; i++) {
3082+
if (h[parts[i]]) {
3083+
key += parts[i] + '=' + h[parts[i]].value + ';';
3084+
}
3085+
}
3086+
if (key) {
3087+
// FNV-1a hash. Cryptographic strength is unnecessary;
3088+
// we only need distinct cache keys for distinct header combinations.
3089+
var hash = 2166136261;
3090+
for (var j = 0; j < key.length; j++) {
3091+
hash ^= key.charCodeAt(j);
3092+
hash = (hash * 16777619) | 0;
3093+
}
3094+
event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) };
3095+
}
3096+
return event.request;
3097+
}
3098+
",
3099+
"FunctionConfig": {
3100+
"Comment": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3101+
"Runtime": "cloudfront-js-2.0",
3102+
},
3103+
"Name": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9",
3104+
},
3105+
"Type": "AWS::CloudFront::Function",
3106+
},
30483107
"WebappCloudFrontInvalidation588CF152": {
30493108
"DeletionPolicy": "Delete",
30503109
"DependsOn": [
@@ -3918,6 +3977,7 @@ service iptables save",
39183977
"X-HTTP-Method-Override",
39193978
"X-HTTP-Method",
39203979
"X-Method-Override",
3980+
"x-nextjs-cache-key",
39213981
],
39223982
},
39233983
"QueryStringsConfig": {

0 commit comments

Comments
 (0)