open-next-cdk
Version:
Deploy a NextJS app using OpenNext packaging to serverless AWS using CDK
148 lines (136 loc) • 4.93 kB
text/typescript
import qs 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;
/**
* This Lambda@Edge handler fixes s3 requests, fixes the host header, and
* signs requests as they're destined for Lambda Function URL that requires
* IAM Auth.
*/
export const handler: CloudFrontRequestHandler = async (event) => {
const request = event.Records[0].cf.request;
if (debug) console.log('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);
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 || '');
}
/**
* When `NextjsDistributionProps.functionUrlAuthType` is set to
* `lambda.FunctionUrlAuthType.AWS_IAM` we need to sign the `CloudFrontRequest`s
* with AWS IAM SigV4 so that CloudFront can invoke the Nextjs server and image
* optimization functions via function URLs. When configured, this lambda@edge
* function has the permission, lambda:InvokeFunctionUrl, to invoke both
* functions.
* @link https://medium.com/@dario_26152/restrict-access-to-lambda-functionurl-to-cloudfront-using-aws-iam-988583834705
*/
export async function signRequest(request: CloudFrontRequest) {
if (!sigv4) {
const region = getRegionFromLambdaUrl(request.origin?.custom?.domainName || '');
sigv4 = getSigV4(region);
}
const headerBag = cfHeadersToHeaderBag(request);
let body: string | undefined;
if (request.body?.data) {
body = Buffer.from(request.body.data, 'base64').toString();
}
const params = queryStringToParams(request);
const signed = await sigv4.sign({
method: request.method,
headers: headerBag,
hostname: headerBag.host,
path: request.uri,
body,
query: params,
protocol: 'https',
});
request.headers = headerBagToCfHeaders(signed.headers);
}
function getSigV4(region: string): SignatureV4 {
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const sessionToken = process.env.AWS_SESSION_TOKEN;
if (!region) throw new Error('AWS_REGION missing');
if (!accessKeyId) throw new Error('AWS_ACCESS_KEY_ID missing');
if (!secretAccessKey) throw new Error('AWS_SECRET_ACCESS_KEY missing');
if (!sessionToken) throw new Error('AWS_SESSION_TOKEN missing');
return new SignatureV4({
service: 'lambda',
region,
credentials: {
accessKeyId,
secretAccessKey,
sessionToken,
},
sha256: Sha256,
});
}
export function getRegionFromLambdaUrl(url: string): string {
const region = url.split('.')[2];
if (!region) throw new Error("Region couldn't be extracted from Lambda Function URL");
return region;
}
type HeaderBag = 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;
}
}
return headerBag;
}
/**
* Converts simple header bag (object) to CloudFront headers
*/
export function headerBagToCfHeaders(headerBag: HeaderBag): CloudFrontHeaders {
const cfHeaders: CloudFrontHeaders = {};
for (const [header, value] of Object.entries(headerBag)) {
cfHeaders[header] = [{ key: header, 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
*/
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;
}
}
return params;
}