From 36e09055d5e7507a4a8db0a6e9ee394b0f5089b0 Mon Sep 17 00:00:00 2001 From: Kenji Kono Date: Fri, 20 Mar 2026 12:34:34 +0900 Subject: [PATCH] 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 --- cdk/.gitignore | 1 + .../cf-function/cache-key.js | 39 ++++++++++++ .../cf-lambda-furl-service/service.ts | 22 +++++++ ...pp-starter-kit-without-domain.test.ts.snap | 63 +++++++++++++++++++ ...-fullstack-webapp-starter-kit.test.ts.snap | 63 +++++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 cdk/lib/constructs/cf-lambda-furl-service/cf-function/cache-key.js diff --git a/cdk/.gitignore b/cdk/.gitignore index ed3ed17..03a9b0e 100644 --- a/cdk/.gitignore +++ b/cdk/.gitignore @@ -1,5 +1,6 @@ *.js !jest.config.js +!lib/constructs/cf-lambda-furl-service/cf-function/*.js *.d.ts node_modules diff --git a/cdk/lib/constructs/cf-lambda-furl-service/cf-function/cache-key.js b/cdk/lib/constructs/cf-lambda-furl-service/cf-function/cache-key.js new file mode 100644 index 0000000..02ec2e8 --- /dev/null +++ b/cdk/lib/constructs/cf-lambda-furl-service/cf-function/cache-key.js @@ -0,0 +1,39 @@ +// CloudFront Functions JS 2.0 +// Combines Next.js RSC-related headers into a single hashed cache key header +// to prevent cache poisoning between HTML and RSC flight responses. +// +// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch, +// next-router-segment-prefetch (and next-url for interception routes). +// CloudFront ignores Vary and requires explicit cache key configuration, +// but its Cache Policy has a 10-header limit. This function hashes all +// RSC headers into one header to stay within the limit. +async function handler(event) { + var h = event.request.headers; + var parts = [ + 'rsc', + 'next-router-prefetch', + 'next-router-state-tree', + 'next-router-segment-prefetch', + 'next-url', + ]; + var key = ''; + for (var i = 0; i < parts.length; i++) { + if (h[parts[i]]) { + key += parts[i] + '=' + h[parts[i]].value + ';'; + } + } + if (key) { + // FNV-1a hash (32-bit). Cryptographic strength is unnecessary; + // we only need distinct cache keys for distinct header combinations. + // See: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function + var FNV_OFFSET_BASIS = 2166136261; + var FNV_PRIME = 16777619; + var hash = FNV_OFFSET_BASIS; + for (var j = 0; j < key.length; j++) { + hash ^= key.charCodeAt(j); + hash = (hash * FNV_PRIME) | 0; + } + event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) }; + } + return event.request; +} diff --git a/cdk/lib/constructs/cf-lambda-furl-service/service.ts b/cdk/lib/constructs/cf-lambda-furl-service/service.ts index 39fc7ae..1d6ba16 100644 --- a/cdk/lib/constructs/cf-lambda-furl-service/service.ts +++ b/cdk/lib/constructs/cf-lambda-furl-service/service.ts @@ -8,11 +8,16 @@ import { CachePolicy, CacheQueryStringBehavior, Distribution, + Function as CfFunction, + FunctionCode as CfFunctionCode, + FunctionEventType, + FunctionRuntime, LambdaEdgeEventType, OriginRequestPolicy, SecurityPolicyProtocol, } from 'aws-cdk-lib/aws-cloudfront'; import { FunctionUrlOrigin } from 'aws-cdk-lib/aws-cloudfront-origins'; +import * as path from 'path'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; import { ARecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; @@ -89,6 +94,9 @@ export class CloudFrontLambdaFunctionUrlService extends Construct { 'X-HTTP-Method-Override', 'X-HTTP-Method', 'X-Method-Override', + // Hashed Next.js RSC headers set by the CloudFront Function below. + // See cf-function/cache-key.js for details. + 'x-nextjs-cache-key', ), defaultTtl: Duration.seconds(0), cookieBehavior: CacheCookieBehavior.all(), @@ -96,6 +104,14 @@ export class CloudFrontLambdaFunctionUrlService extends Construct { enableAcceptEncodingGzip: true, }); + // CloudFront Function to hash Next.js RSC headers into a single cache key header. + // This prevents cache poisoning between HTML and RSC flight responses while + // staying within CloudFront's 10-header limit on Cache Policies. + const cacheKeyFunction = new CfFunction(this, 'CacheKeyFunction', { + runtime: FunctionRuntime.JS_2_0, + code: CfFunctionCode.fromFile({ filePath: path.join(__dirname, 'cf-function', 'cache-key.js') }), + }); + const distribution = new Distribution(this, 'Resource', { comment: `CloudFront for ${serviceName}`, defaultBehavior: { @@ -103,6 +119,12 @@ export class CloudFrontLambdaFunctionUrlService extends Construct { cachePolicy, allowedMethods: AllowedMethods.ALLOW_ALL, originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER, + functionAssociations: [ + { + function: cacheKeyFunction, + eventType: FunctionEventType.VIEWER_REQUEST, + }, + ], edgeLambdas: [ { functionVersion: signPayloadHandler.versionArn(this), diff --git a/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit-without-domain.test.ts.snap b/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit-without-domain.test.ts.snap index c3bca2c..9dc9ea1 100644 --- a/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit-without-domain.test.ts.snap +++ b/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit-without-domain.test.ts.snap @@ -2980,6 +2980,17 @@ service iptables save", "Ref": "WebappSharedCachePolicy14FEE4A0", }, "Compress": true, + "FunctionAssociations": [ + { + "EventType": "viewer-request", + "FunctionARN": { + "Fn::GetAtt": [ + "WebappCacheKeyFunction6C227CE2", + "FunctionARN", + ], + }, + }, + ], "LambdaFunctionAssociations": [ { "EventType": "origin-request", @@ -3200,6 +3211,57 @@ service iptables save", "Type": "AWS::ECR::Repository", "UpdateReplacePolicy": "Delete", }, + "WebappCacheKeyFunction6C227CE2": { + "Properties": { + "AutoPublish": true, + "FunctionCode": "// CloudFront Functions JS 2.0 +// Combines Next.js RSC-related headers into a single hashed cache key header +// to prevent cache poisoning between HTML and RSC flight responses. +// +// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch, +// next-router-segment-prefetch (and next-url for interception routes). +// CloudFront ignores Vary and requires explicit cache key configuration, +// but its Cache Policy has a 10-header limit. This function hashes all +// RSC headers into one header to stay within the limit. +async function handler(event) { + var h = event.request.headers; + var parts = [ + 'rsc', + 'next-router-prefetch', + 'next-router-state-tree', + 'next-router-segment-prefetch', + 'next-url', + ]; + var key = ''; + for (var i = 0; i < parts.length; i++) { + if (h[parts[i]]) { + key += parts[i] + '=' + h[parts[i]].value + ';'; + } + } + if (key) { + // FNV-1a hash (32-bit). Cryptographic strength is unnecessary; + // we only need distinct cache keys for distinct header combinations. + // See: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function + var FNV_OFFSET_BASIS = 2166136261; + var FNV_PRIME = 16777619; + var hash = FNV_OFFSET_BASIS; + for (var j = 0; j < key.length; j++) { + hash ^= key.charCodeAt(j); + hash = (hash * FNV_PRIME) | 0; + } + event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) }; + } + return event.request; +} +", + "FunctionConfig": { + "Comment": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9", + "Runtime": "cloudfront-js-2.0", + }, + "Name": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9", + }, + "Type": "AWS::CloudFront::Function", + }, "WebappCloudFrontInvalidation588CF152": { "DeletionPolicy": "Delete", "DependsOn": [ @@ -4094,6 +4156,7 @@ service iptables save", "X-HTTP-Method-Override", "X-HTTP-Method", "X-Method-Override", + "x-nextjs-cache-key", ], }, "QueryStringsConfig": { diff --git a/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit.test.ts.snap b/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit.test.ts.snap index 520dfb6..4f63f31 100644 --- a/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit.test.ts.snap +++ b/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit.test.ts.snap @@ -2815,6 +2815,17 @@ service iptables save", "Ref": "WebappSharedCachePolicy14FEE4A0", }, "Compress": true, + "FunctionAssociations": [ + { + "EventType": "viewer-request", + "FunctionARN": { + "Fn::GetAtt": [ + "WebappCacheKeyFunction6C227CE2", + "FunctionARN", + ], + }, + }, + ], "LambdaFunctionAssociations": [ { "EventType": "origin-request", @@ -3045,6 +3056,57 @@ service iptables save", "Type": "AWS::ECR::Repository", "UpdateReplacePolicy": "Delete", }, + "WebappCacheKeyFunction6C227CE2": { + "Properties": { + "AutoPublish": true, + "FunctionCode": "// CloudFront Functions JS 2.0 +// Combines Next.js RSC-related headers into a single hashed cache key header +// to prevent cache poisoning between HTML and RSC flight responses. +// +// Next.js App Router sets Vary: rsc, next-router-state-tree, next-router-prefetch, +// next-router-segment-prefetch (and next-url for interception routes). +// CloudFront ignores Vary and requires explicit cache key configuration, +// but its Cache Policy has a 10-header limit. This function hashes all +// RSC headers into one header to stay within the limit. +async function handler(event) { + var h = event.request.headers; + var parts = [ + 'rsc', + 'next-router-prefetch', + 'next-router-state-tree', + 'next-router-segment-prefetch', + 'next-url', + ]; + var key = ''; + for (var i = 0; i < parts.length; i++) { + if (h[parts[i]]) { + key += parts[i] + '=' + h[parts[i]].value + ';'; + } + } + if (key) { + // FNV-1a hash (32-bit). Cryptographic strength is unnecessary; + // we only need distinct cache keys for distinct header combinations. + // See: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function + var FNV_OFFSET_BASIS = 2166136261; + var FNV_PRIME = 16777619; + var hash = FNV_OFFSET_BASIS; + for (var j = 0; j < key.length; j++) { + hash ^= key.charCodeAt(j); + hash = (hash * FNV_PRIME) | 0; + } + event.request.headers['x-nextjs-cache-key'] = { value: String(hash >>> 0) }; + } + return event.request; +} +", + "FunctionConfig": { + "Comment": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9", + "Runtime": "cloudfront-js-2.0", + }, + "Name": "us-west-2ServerlessWebappCacheKeyFunction86D1ABE9", + }, + "Type": "AWS::CloudFront::Function", + }, "WebappCloudFrontInvalidation588CF152": { "DeletionPolicy": "Delete", "DependsOn": [ @@ -3918,6 +3980,7 @@ service iptables save", "X-HTTP-Method-Override", "X-HTTP-Method", "X-Method-Override", + "x-nextjs-cache-key", ], }, "QueryStringsConfig": {