Skip to content

Commit

Permalink
fix: server actions by removing origin group + distribution refactor (#…
Browse files Browse the repository at this point in the history
…131)

* fix: server actions by removing origin group

* fix: make jsii compatible

* chore: update docs

* docs: v4 breaking changes

* chore: self mutation

Signed-off-by: github-actions <[email protected]>

* feat: validate number cache behaviors and patterns

* refactor: rename NextjsLambda to NextjsServer

* feat: remove Lambda@Edge from no auth function urls; update origin request and cache policies; fix image serving for iam auth function urls

* chore: self mutation

Signed-off-by: github-actions <[email protected]>

* docs: explain v4 upgrade Lambda@Edge replicated function deletion

* chore: self mutation

Signed-off-by: github-actions <[email protected]>

---------

Signed-off-by: github-actions <[email protected]>
Co-authored-by: github-actions <[email protected]>
  • Loading branch information
bestickley and github-actions authored Aug 8, 2023
1 parent 455c49a commit 80ae07b
Show file tree
Hide file tree
Showing 15 changed files with 1,708 additions and 1,784 deletions.
1 change: 0 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.packageManager": "yarn",
}
2,783 changes: 1,370 additions & 1,413 deletions API.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ new Nextjs(this, 'Web', {
});
```

## Important Notes
- Due to CloudFront's Distribution Cache Behavior pattern matching limitations, a cache behavior will be created for each top level file or directory in your `public/` folder. CloudFront has a soft limit of [25 cache behaviors per distribution](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html#limits-web-distributions). Therefore, it's recommended to include all assets that can be under a top level folder like `public/static/`. Learn more in open-next docs [here](https://github.com/sst/open-next/blob/main/README.md#workaround-create-one-cache-behavior-per-top-level-file-and-folder-in-public-aws-specific).

## Documentation

Available on [Construct Hub](https://constructs.dev/packages/cdk-nextjs-standalone/).
Expand Down Expand Up @@ -124,6 +127,19 @@ Don't manually update package.json or use npm CLI. Update dependencies in .proje

## Breaking changes

- v4.0.0
- Renamed `NextjsLambda` to `NextjsServer`
- Renamed `ImageOptimizationLambda` to `NextjsImage`
- Renamed `NextjsCachePolicyProps.lambdaCachePolicy` to `NextjsCachePolicyProps.serverCachePolicy`
- Removed `NextjsOriginRequestPolicyProps.fallbackOriginRequestPolicy`
- Renamed `NextjsOriginRequestPolicyProps.lambdaOriginRequestPolicy` to `NextjsOriginRequestPolicyProps.serverOriginRequestPolicy`
- Removed `NextjsDistribution.staticCachePolicyProps`
- Renamed `NextjsDistribution.lambdaCachePolicyProps` to `NextjsDistribution.serverCachePolicyProps`
- Renamed `NextjsDistribution.lambdaOriginRequestPolicyProps` to `NextjsDistribution.serverOriginRequestPolicyProps`
- Removed `NextjsDistribution.fallbackOriginRequestPolicyProps`
- Removed `NextjsDistribution.imageOptimizationOriginRequestPolicyProps`
- NOTE: when upgrading to v4 from v3, the Lambda@Edge function will be renamed or removed. CloudFormation will fail to delete the function b/c they're replicated a take ~15 min to delete (more [here](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-edge-delete-replicas.html)). You can either deploy CloudFormation with it's "no rollback" feature for a clean deployment or mark the Lambda@Edge function as "retain on delete".

- v3.0.0: Using open-next for building, ARM64 architecture for image handling, new build options.

- v2.0.0: SST wrapper changed, lambda/assets/distribution defaults now are in the `defaults` prop, refactored distribution settings into the new NextjsDistribution construct. If you are upgrading, you must temporarily remove the `customDomain` on your existing 1.x.x app before upgrading to >=2.x.x because the CloudFront distribution will get recreated due to refactoring, and the custom domain must be globally unique across all CloudFront distributions. Prepare for downtime.
16 changes: 0 additions & 16 deletions assets/lambda@edge/LambdaOriginRequest.ts

This file was deleted.

95 changes: 84 additions & 11 deletions assets/lambda@edge/LambdaOriginRequestIamAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,27 @@
import type { CloudFrontRequestEvent } from 'aws-lambda';
import { getRegionFromLambdaUrl, isLambdaUrlRequest, signRequest } from './LambdaOriginRequestIamAuth';
import { getRegionFromLambdaUrl, signRequest } from './LambdaOriginRequestIamAuth';

describe('LambdaOriginRequestIamAuth', () => {
test('signRequest should add x-amz headers', async () => {
// dummy AWS credentials
process.env = { ...process.env, ...getFakeAwsCreds() };
const event = getFakeCloudFrontLambdaUrlRequest();
const event = getFakePageRequest();
const request = event.Records[0].cf.request;
await signRequest(request);
const securityHeaders = ['x-amz-date', 'x-amz-security-token', 'x-amz-content-sha256', 'authorization'];
const hasSignedHeaders = securityHeaders.every((h) => h in request.headers);
expect(hasSignedHeaders).toBe(true);
});

test('isLambdaFunctionUrl should correctly identity Lambda URL', () => {
const event = getFakeCloudFrontLambdaUrlRequest();
const request = event.Records[0].cf.request;
const actual = isLambdaUrlRequest(request);
expect(actual).toBe(true);
});

test('getRegionFromLambdaUrl should correctly get region', () => {
const event = getFakeCloudFrontLambdaUrlRequest();
const event = getFakePageRequest();
const request = event.Records[0].cf.request;
const actual = getRegionFromLambdaUrl(request.origin?.custom?.domainName || '');
expect(actual).toBe('us-east-1');
});
});

function getFakeCloudFrontLambdaUrlRequest(): CloudFrontRequestEvent {
function getFakePageRequest(): CloudFrontRequestEvent {
return {
Records: [
{
Expand Down Expand Up @@ -129,3 +122,83 @@ function getFakeAwsCreds() {
'ZQoJb3JpZ2luX2VjEFgaCXVzLWVhc3QtMSJGMEQCIHijzdTXh59aSe2hRfCWpFd2/jacPUC+8rCq3qBIiuG2AiAGX8jqld+p04nPYfuShi1lLN/Z1hEXG9QSNEmEFLTxGSqmAgiR//////////8BEAIaDDI2ODkxNDQ2NTIzMSIMrAMO5/GTvMgoG+chKvoB4f4V1TfkZiHOlmeMK6Ep58mav65A0WU3K9WPzdrJojnGqqTuS85zTlKhm3lfmMxCOtwS/OlOuiBQ1MZNlksK2je1FazgbXN46fNSi+iHiY9VfyRAd0wSLmXB8FFrCGsU92QOy/+deji0qIVadsjEyvBRxzQj5oIUI5sb74Yt7uNvka9fVZcT4s4IndYda0N7oZwIrApCuzzBMuoMAhabmgVrZTbiLmvOiFHS2XZWBySABdygqaIzfV7G4hjckvcXhtxpkw+HJUZTNzVUlspghzte1UG6VvIRV8ax3kWA3zqm8nA/1gHkl40DubJIXz1AJbg5Cps5moE1pjD7vNijBjqeAZh0Q/e0awIHnV4dXMfXUu5mWJ7Db9K1eUlSSL9FyiKeKd94HEdrbIrnPuIWVT/I/5RjNm7NgPYiqmpyx3fSpVcq9CKws0oEfBw6J9Hxk0IhV8yWFZYNMWIarUUZdmL9vVeJmFZmwyL4JjY1s/SZIU/oa8DtvkmP4RG4tTJfpyyhoKL0wJOevkYyoigNllBlLN59SZAT8CCADpN/B+sK',
};
}

function getFakeImageEvent(): CloudFrontRequestEvent {
return {
Records: [
{
cf: {
config: {
distributionDomainName: 'd6b8brjqfujeb.cloudfront.net',
distributionId: 'EHX2SDUU61T7U',
eventType: 'origin-request',
requestId: '',
},
request: {
body: {
action: 'read-only',
data: '',
encoding: 'base64',
inputTruncated: false,
},
clientIp: '35.148.139.0',
headers: {
accept: [
{
key: 'Accept',
value:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
},
],
'x-forwarded-for': [
{
key: 'X-Forwarded-For',
value: '35.148.139.0',
},
],
'user-agent': [
{
key: 'User-Agent',
value: 'Amazon CloudFront',
},
],
via: [
{
key: 'Via',
value: '2.0 56233ac1c78ee7b920e664cc0c7f287e.cloudfront.net (CloudFront)',
},
],
'accept-encoding': [
{
key: 'Accept-Encoding',
value: 'br,gzip',
},
],
host: [
{
key: 'Host',
value: 'lqlihcxizzcsefhpfcx2rnkgnu0pzrar.lambda-url.us-east-1.on.aws',
},
],
},
method: 'GET',
origin: {
custom: {
customHeaders: {},
domainName: 'lqlihcxizzcsefhpfcx2rnkgnu0pzrar.lambda-url.us-east-1.on.aws',
keepaliveTimeout: 5,
path: '',
port: 443,
protocol: 'https',
readTimeout: 30,
sslProtocols: ['TLSv1.2'],
},
},
querystring: 'url=%2Fprince-akachi-LWkFHEGpleE-unsplash.jpg&w=96&q=75&badParam=bad',
uri: '/_next/image',
},
},
},
],
};
}
92 changes: 43 additions & 49 deletions assets/lambda@edge/LambdaOriginRequestIamAuth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import qs from 'node:querystring';
import qs, { escape } from 'node:querystring';
import { Sha256 } from '@aws-crypto/sha256-js';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import type { CloudFrontHeaders, CloudFrontRequest, CloudFrontRequestHandler } from 'aws-lambda';
import { fixHostHeader, handleS3Request } from './common';

const debug = false;

Expand All @@ -13,23 +12,25 @@ const debug = false;
*/
export const handler: CloudFrontRequestHandler = async (event) => {
const request = event.Records[0].cf.request;
if (debug) console.log('request', JSON.stringify(request, null, 2));
if (debug) console.log('input request', JSON.stringify(request, null, 2));

handleS3Request(request);
fixHostHeader(request);
if (isLambdaUrlRequest(request)) {
await signRequest(request);
}
if (debug) console.log(JSON.stringify(request), null, 2);
escapeQuerystring(request);
await signRequest(request);

if (debug) console.log('output request', JSON.stringify(request), null, 2);
return request;
};

let sigv4: SignatureV4;

export function isLambdaUrlRequest(request: CloudFrontRequest) {
return /[a-z0-9]+\.lambda-url\.[a-z0-9-]+\.on\.aws/.test(request.origin?.custom?.domainName || '');
/**
* Lambda URL will reject query parameters with brackets so we need to encode
* https://github.dev/pwrdrvr/lambda-url-signing/blob/main/packages/edge-to-origin/src/translate-request.ts#L19-L31
*/
function escapeQuerystring(request: CloudFrontRequest) {
request.querystring = request.querystring.replace(/\[/g, '%5B').replace(/]/g, '%5D');
}

let sigv4: SignatureV4;

/**
* When `NextjsDistributionProps.functionUrlAuthType` is set to
* `lambda.FunctionUrlAuthType.AWS_IAM` we need to sign the `CloudFrontRequest`s
Expand All @@ -44,12 +45,14 @@ export async function signRequest(request: CloudFrontRequest) {
const region = getRegionFromLambdaUrl(request.origin?.custom?.domainName || '');
sigv4 = getSigV4(region);
}
const headerBag = cfHeadersToHeaderBag(request);
// remove x-forwarded-for b/c it changes from hop to hop
delete request.headers['x-forwarded-for'];
const headerBag = cfHeadersToHeaderBag(request.headers);
let body: string | undefined;
if (request.body?.data) {
body = Buffer.from(request.body.data, 'base64').toString();
}
const params = queryStringToParams(request);
const params = queryStringToQueryParamBag(request.querystring);
const signed = await sigv4.sign({
method: request.method,
headers: headerBag,
Expand Down Expand Up @@ -88,60 +91,51 @@ export function getRegionFromLambdaUrl(url: string): string {
return region;
}

type HeaderBag = Record<string, string>;
/**
* Bag or Map used for HeaderBag or QueryStringParameterBag for `sigv4.sign()`
*/
type Bag = Record<string, string>;
/**
* Converts CloudFront headers (can have array of header values) to simple
* header bag (object) required by `sigv4.sign`
*
* NOTE: only includes headers allowed by origin policy to prevent signature
* mismatch
*/
export function cfHeadersToHeaderBag(request: CloudFrontRequest): HeaderBag {
let headerBag: HeaderBag = {};
for (const [header, values] of Object.entries(request.headers)) {
// don't sign 'x-forwarded-for' b/c it changes from hop to hop
if (header === 'x-forwarded-for') continue;
if (request.uri === '_next/image') {
// _next/image origin policy only allows accept
if (header === 'accept') {
headerBag[header] = values[0].value;
}
} else {
headerBag[header] = values[0].value;
}
export function cfHeadersToHeaderBag(headers: CloudFrontHeaders): Bag {
let headerBag: Bag = {};
// assume first header value is the best match
// headerKey is case insensitive whereas key (adjacent property value that is
// not destructured) is case sensitive. we arbitrarily use case insensitive key
for (const [headerKey, [{ value }]] of Object.entries(headers)) {
headerBag[headerKey] = value;
}
return headerBag;
}

/**
* Converts simple header bag (object) to CloudFront headers
*/
export function headerBagToCfHeaders(headerBag: HeaderBag): CloudFrontHeaders {
export function headerBagToCfHeaders(headerBag: Bag): CloudFrontHeaders {
const cfHeaders: CloudFrontHeaders = {};
for (const [header, value] of Object.entries(headerBag)) {
cfHeaders[header] = [{ key: header, value }];
for (const [headerKey, value] of Object.entries(headerBag)) {
/*
When your Lambda function adds or modifies request headers and you don't include the header key field, Lambda@Edge automatically inserts a header key using the header name that you provide. Regardless of how you've formatted the header name, the header key that's inserted automatically is formatted with initial capitalization for each part, separated by hyphens (-).
See: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html
*/
cfHeaders[headerKey] = [{ value }];
}
return cfHeaders;
}

/**
* Converts CloudFront querystring to `HttpRequest.query` for IAM Sig V4
*
* NOTE: only includes query parameters allowed at origin to prevent signature
* mismatch errors
* Converts CloudFront querystring to QueryParamaterBag for IAM Sig V4
*/
export function queryStringToParams(request: CloudFrontRequest) {
const params: Record<string, string> = {};
const _params = new URLSearchParams(request.querystring);
for (const [k, v] of _params) {
if (request.uri === '_next/image') {
// _next/image origin policy only allows these querystrings
if (['url', 'q', 'w'].includes(k)) {
params[k] = v;
}
} else {
params[k] = v;
}
export function queryStringToQueryParamBag(querystring: string): Bag {
const oldParams = new URLSearchParams(querystring);
const newParams: Bag = {};
for (const [k, v] of oldParams) {
newParams[k] = v;
}
return params;
return newParams;
}
29 changes: 0 additions & 29 deletions assets/lambda@edge/common.ts

This file was deleted.

Loading

0 comments on commit 80ae07b

Please sign in to comment.