diff --git a/platform/functions/oac-edge-signer/index.ts b/platform/functions/oac-edge-signer/index.ts new file mode 100644 index 0000000000..8646083565 --- /dev/null +++ b/platform/functions/oac-edge-signer/index.ts @@ -0,0 +1,59 @@ +// Lambda@Edge function for automatic SHA256 header signing +// This function adds the required x-amz-content-sha256 header for POST/PUT/PATCH requests +// going to Lambda function URLs with Origin Access Control enabled. + +import { CloudFrontRequestHandler } from "aws-lambda"; +import crypto from "node:crypto"; + +export const handler: CloudFrontRequestHandler = async (event) => { + const request = event.Records[0].cf.request; + + // Only process requests that need SHA256 signing (methods with body) + if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) { + return request; + } + + // Check if body was truncated (exceeds 1MB Lambda@Edge limit) + if (request.body?.inputTruncated) { + return { + status: "413", + statusDescription: "Payload Too Large", + headers: { + "content-type": [{ key: "Content-Type", value: "application/json" }], + }, + body: JSON.stringify({ + error: + "Request body exceeds 1MB Lambda@Edge limit. Use presigned S3 URLs for large uploads.", + }), + }; + } + + try { + // Get the request body as raw bytes (never convert to UTF-8 string) + const bodyBuffer = request.body?.data + ? request.body.encoding === "base64" + ? Buffer.from(request.body.data, "base64") + : Buffer.from(request.body.data) + : Buffer.alloc(0); + + // Compute SHA256 hash of the raw bytes + const hash = crypto.createHash("sha256").update(bodyBuffer).digest("hex"); + + // Add the x-amz-content-sha256 header in CloudFront format + request.headers["x-amz-content-sha256"] = [ + { + key: "x-amz-content-sha256", + value: hash, + }, + ]; + + console.log( + `Added SHA256 header for ${request.method} request to ${request.uri}: ${hash}`, + ); + } catch (error) { + console.error("Error computing SHA256 hash:", error); + // Continue without the header rather than failing the request + } + + return request; +}; diff --git a/platform/scripts/build.mjs b/platform/scripts/build.mjs index 2e7fab92a4..7158593462 100644 --- a/platform/scripts/build.mjs +++ b/platform/scripts/build.mjs @@ -3,7 +3,7 @@ import fs from "fs/promises"; // Require transpile await Promise.all( - ["ssr-warmer", "vector-handler"].map((file) => + ["ssr-warmer", "vector-handler", "oac-edge-signer"].map((file) => esbuild.build({ bundle: true, minify: true, diff --git a/platform/src/components/aws/helpers/arn.ts b/platform/src/components/aws/helpers/arn.ts index 54d6dc0196..d5636efb6d 100644 --- a/platform/src/components/aws/helpers/arn.ts +++ b/platform/src/components/aws/helpers/arn.ts @@ -94,6 +94,30 @@ export function parseRoleArn(arn: string) { return { roleName }; } +export function parseLambdaEdgeArn(arn: string) { + // First validate it's a Lambda function ARN + const { functionName } = parseFunctionArn(arn); + + // arn:aws:lambda:region:account-id:function:function-name:version + const parts = arn.split(":"); + const region = parts[3]; + const version = parts[7]; + + if (region !== "us-east-1") { + throw new VisibleError( + `Lambda@Edge functions must be deployed in us-east-1 region. Got region: ${region}`, + ); + } + + if (!version || version === "$LATEST") { + throw new VisibleError( + `Lambda@Edge requires a qualified ARN (with version). Got: ${arn}`, + ); + } + + return { functionName, region, version }; +} + export function parseElasticSearch(arn: string) { // arn:aws:es:region:account-id:domain/domain-name const tableName = arn.split("/")[1]; diff --git a/platform/src/components/aws/router.ts b/platform/src/components/aws/router.ts index b6a665e55f..3a53287d61 100644 --- a/platform/src/components/aws/router.ts +++ b/platform/src/components/aws/router.ts @@ -2010,7 +2010,7 @@ async function routeSite(kvNamespace, metadata) { // Route to image optimizer if (metadata.image && baselessUri.startsWith(metadata.image.route)) { - setUrlOrigin(metadata.image.host); + setUrlOrigin(metadata.image.host, metadata.image.originAccessControlConfig ? { originAccessControlConfig: metadata.image.originAccessControlConfig } : undefined); return; } @@ -2149,6 +2149,9 @@ function setUrlOrigin(urlHost, override) { if (override.timeouts) { origin.timeouts = override.timeouts; } + if (override.originAccessControlConfig) { + origin.originAccessControlConfig = override.originAccessControlConfig; + } cf.updateRequestOrigin(origin); } @@ -2187,6 +2190,12 @@ export type KV_SITE_METADATA = { image?: { host: string; route: string; + originAccessControlConfig?: { + enabled: boolean; + signingBehavior: string; + signingProtocol: string; + originType: string; + }; }; servers?: [string, number, number][]; origin?: { diff --git a/platform/src/components/aws/ssr-site.ts b/platform/src/components/aws/ssr-site.ts index dcf5e43819..38ff20cc89 100644 --- a/platform/src/components/aws/ssr-site.ts +++ b/platform/src/components/aws/ssr-site.ts @@ -12,8 +12,11 @@ import { ComponentResourceOptions, Resource, } from "@pulumi/pulumi"; +import * as pulumi from "@pulumi/pulumi"; +import * as aws from "@pulumi/aws"; import { Cdn, CdnArgs } from "./cdn.js"; -import { Function, FunctionArgs } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; +import { parseLambdaEdgeArn } from "./helpers/arn.js"; import { Bucket, BucketArgs } from "./bucket.js"; import { BucketFile, BucketFiles } from "./providers/bucket-files.js"; import { logicalName } from "../naming.js"; @@ -28,7 +31,7 @@ import { VisibleError } from "../error.js"; import { Cron } from "./cron.js"; import { BaseSiteFileOptions, getContentType } from "../base/base-site.js"; import { BaseSsrSiteArgs, buildApp } from "../base/base-ssr-site.js"; -import { cloudfront, getRegionOutput, lambda, Region } from "@pulumi/aws"; +import { cloudfront, getRegionOutput, lambda, Region, iam } from "@pulumi/aws"; import { KvKeys } from "./providers/kv-keys.js"; import { useProvider } from "./helpers/provider.js"; import { Link } from "../link.js"; @@ -42,7 +45,8 @@ import { RouterRouteArgs, } from "./router.js"; import { DistributionInvalidation } from "./providers/distribution-invalidation.js"; -import { toSeconds } from "../duration.js"; +import { toSeconds, DurationSeconds } from "../duration.js"; +import { Size, toMBs } from "../size.js"; import { KvRoutesUpdate } from "./providers/kv-routes-update.js"; import { CONSOLE_URL, getQuota } from "./helpers/quota.js"; import { toPosix } from "../path.js"; @@ -121,6 +125,103 @@ export interface SsrSiteArgs extends BaseSsrSiteArgs { route?: Prettify; router?: Prettify; cachePolicy?: Input; + /** + * Configure Lambda function protection through CloudFront. + * + * @default `"none"` + * + * The available options are: + * - `"none"`: Lambda URLs are publicly accessible. + * - `"oac"`: Lambda URLs protected by CloudFront Origin Access Control. Requires manual `x-amz-content-sha256` header for POST requests. Use when you control all POST requests. + * - `"oac-with-edge-signing"`: Full protection with automatic header signing via Lambda@Edge. Works with external webhooks and callbacks. Higher cost and latency but works out of the box. + * + * :::note + * When using `"oac-with-edge-signing"`, request bodies are limited to 1MB due to Lambda@Edge payload limits. For file uploads larger than 1MB, consider using presigned S3 URLs or the `"oac"` mode with manual header signing. + * ::: + * + * :::note + * When removing a stage that uses `"oac-with-edge-signing"`, deletion may take 5-10 minutes while AWS removes the Lambda@Edge replicated functions from all edge locations. + * ::: + * + * @example + * ```js + * // No protection (default) + * { + * protection: "none" + * } + * ``` + * + * @example + * ```js + * // OAC protection, manual header signing required + * { + * protection: "oac" + * } + * ``` + * + * @example + * ```js + * // Full protection with automatic Lambda@Edge + * { + * protection: "oac-with-edge-signing" + * } + * ``` + * + * @example + * ```js + * // Custom Lambda@Edge configuration + * { + * protection: { + * mode: "oac-with-edge-signing", + * edgeFunction: { + * memory: "256 MB", + * timeout: "10 seconds" + * } + * } + * } + * ``` + * + * @example + * ```js + * // Use existing Lambda@Edge function + * { + * protection: { + * mode: "oac-with-edge-signing", + * edgeFunction: { + * arn: "arn:aws:lambda:us-east-1:123456789012:function:my-signing-function:1" + * } + * } + * } + * ``` + */ + protection?: Input< + | "none" + | "oac" + | "oac-with-edge-signing" + | { + mode: "oac-with-edge-signing"; + edgeFunction?: { + /** + * Custom Lambda@Edge function ARN to use for request signing. + * If provided, this function will be used instead of creating a new one. + * Must be a qualified ARN (with version) and deployed in us-east-1. + */ + arn?: Input; + /** + * Memory size for the auto-created Lambda@Edge function. + * Only used when arn is not provided. + * @default `"128 MB"` + */ + memory?: Input; + /** + * Timeout for the auto-created Lambda@Edge function. + * Only used when arn is not provided. + * @default `"5 seconds"` + */ + timeout?: Input; + }; + } + >; invalidation?: Input< | false | { @@ -671,6 +772,7 @@ export abstract class SsrSite extends Component implements Link.Linkable { const sitePath = regions.apply(() => normalizeSitePath()); const dev = normalizeDev(); const purge = output(args.assets).apply((assets) => assets?.purge ?? false); + const protection = normalizeProtection(); if (dev.enabled) { const server = createDevServer(); @@ -707,6 +809,7 @@ export abstract class SsrSite extends Component implements Link.Linkable { const imageOptimizer = createImageOptimizer(); const assetsUploaded = uploadAssets(); const kvNamespace = buildKvNamespace(); + const edgeFunction = createLambdaEdgeFunction(); let distribution: Cdn | undefined; let distributionId: Output; @@ -877,6 +980,40 @@ async function handler(event) { ? [{ eventType: "viewer-response", functionArn: resFn.arn }] : []), ]), + lambdaFunctionAssociations: all([protection, edgeFunction]).apply( + ([protectionConfig, autoEdgeFunction]) => { + if (protectionConfig.mode !== "oac-with-edge-signing") { + return []; + } + + // Use provided ARN if available + if ( + "edgeFunction" in protectionConfig && + protectionConfig.edgeFunction?.arn + ) { + return [ + { + eventType: "origin-request", + lambdaArn: protectionConfig.edgeFunction.arn, + includeBody: true, + }, + ]; + } + + // Use auto-created function if available + if (autoEdgeFunction) { + return [ + { + eventType: "origin-request", + lambdaArn: autoEdgeFunction.qualifiedArn, + includeBody: true, + }, + ]; + } + + return []; + }, + ), }, }, { parent: self }, @@ -887,6 +1024,79 @@ async function handler(event) { const kvUpdated = createKvEntries(); createInvalidation(); + // Create Lambda permissions based on protection mode + all([distribution, servers, imageOptimizer, protection]).apply( + ([dist, servers, imgOptimizer, protection]) => { + if (!dist) return; + + // Server functions + servers.forEach(({ region, server }) => { + const provider = useProvider(region); + + if (protection.mode === "none") { + // Create explicit public access permission for none mode + new lambda.Permission( + `${name}PublicFunctionUrlAccess${logicalName(region)}`, + { + action: "lambda:InvokeFunctionUrl", + function: server.nodes.function.name, + principal: "*", + statementId: "FunctionURLAllowPublicAccess", + functionUrlAuthType: "NONE", + }, + { provider, parent: self }, + ); + } else if ( + protection.mode === "oac" || + protection.mode === "oac-with-edge-signing" + ) { + // Create CloudFront-specific permission for OAC modes + new lambda.Permission( + `${name}CloudFrontFunctionUrlAccess${logicalName(region)}`, + { + action: "lambda:InvokeFunctionUrl", + function: server.nodes.function.name, + principal: "cloudfront.amazonaws.com", + sourceArn: dist.nodes.distribution.arn, + }, + { provider, parent: self }, + ); + } + }); + + // Image optimizer + if (imgOptimizer) { + if (protection.mode === "none") { + new lambda.Permission( + `${name}ImageOptimizerPublicFunctionUrlAccess`, + { + action: "lambda:InvokeFunctionUrl", + function: imgOptimizer.nodes.function.name, + principal: "*", + statementId: "FunctionURLAllowPublicAccess", + functionUrlAuthType: "NONE", + }, + { parent: self }, + ); + } else if ( + protection.mode === "oac" || + protection.mode === "oac-with-edge-signing" + ) { + new lambda.Permission( + `${name}ImageOptimizerCloudFrontFunctionUrlAccess`, + { + action: "lambda:InvokeFunctionUrl", + function: imgOptimizer.nodes.function.name, + principal: "cloudfront.amazonaws.com", + sourceArn: dist.nodes.distribution.arn, + }, + { parent: self }, + ); + } + } + }, + ); + const server = servers.apply((servers) => servers[0]?.server); this.bucket = bucket; this.cdn = distribution; @@ -1030,6 +1240,108 @@ async function handler(event) { }); } + function normalizeProtection() { + return output(args.protection).apply((protection) => { + // Default to "none" if not specified + if (!protection) return { mode: "none" as const }; + + // Handle string values + if (typeof protection === "string") { + return { mode: protection }; + } + + // Handle object form - validate ARN if provided + if ( + protection.mode === "oac-with-edge-signing" && + "edgeFunction" in protection && + protection.edgeFunction?.arn + ) { + const arn = protection.edgeFunction.arn; + if (typeof arn === "string") { + parseLambdaEdgeArn(arn); + } + } + + return protection; + }); + } + + function createLambdaEdgeFunction() { + return protection.apply((protectionConfig) => { + // Only create function if mode is oac-with-edge-signing and no ARN is provided + if ( + protectionConfig.mode !== "oac-with-edge-signing" || + ("edgeFunction" in protectionConfig && + protectionConfig.edgeFunction?.arn) + ) { + return undefined; + } + + const edgeConfig = + "edgeFunction" in protectionConfig + ? protectionConfig.edgeFunction + : {}; + const memory = edgeConfig?.memory ? toMBs(edgeConfig.memory) : 128; + const timeout = edgeConfig?.timeout ? toSeconds(edgeConfig.timeout) : 5; + + // Create IAM role for Lambda@Edge using SST transform pattern + const edgeRole = new aws.iam.Role( + ...transform( + undefined, + `${name}EdgeFunctionRole`, + { + assumeRolePolicy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: [ + "lambda.amazonaws.com", + "edgelambda.amazonaws.com", + ], + }, + }, + ], + }), + managedPolicyArns: [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + }, + { parent: self }, + ), + ); + + // Create the Lambda@Edge function using SST transform pattern + const edgeFunction = new aws.lambda.Function( + ...transform( + undefined, + `${name}EdgeFunction`, + { + runtime: "nodejs22.x", + handler: "index.handler", + role: edgeRole.arn, + code: new pulumi.asset.FileArchive( + path.join($cli.paths.platform, "dist", "oac-edge-signer"), + ), + publish: true, // Required for Lambda@Edge + timeout: timeout, + memorySize: memory, + description: `${name} Lambda@Edge function for OAC request signing`, + }, + { + parent: self, + // Lambda@Edge functions must be created in us-east-1 + provider: useProvider("us-east-1"), + }, + ), + ); + + return edgeFunction; + }); + } + function createDevServer() { return new Function( ...transform( @@ -1159,7 +1471,13 @@ async function handler(event) { ...(planServer.layers ?? []), ...(layers ?? []), ]), - url: true, + url: { + authorization: protection.apply((p) => + p.mode === "oac" || p.mode === "oac-with-edge-signing" + ? "iam" + : "none", + ), + }, dev: false, _skipHint: true, }, @@ -1238,7 +1556,13 @@ async function handler(event) { }, ], ...imageOptimizer.function, - url: true, + url: { + authorization: protection.apply((p) => + p.mode === "oac" || p.mode === "oac-with-edge-signing" + ? "iam" + : "none", + ), + }, dev: false, _skipMetadata: true, _skipHint: true, @@ -1381,8 +1705,17 @@ async function handler(event) { plan, bucket.nodes.bucket.bucketRegionalDomainName, timeout, + protection, ]).apply( - ([servers, imageOptimizer, outputPath, plan, bucketDomain, timeout]) => + ([ + servers, + imageOptimizer, + outputPath, + plan, + bucketDomain, + timeout, + protectionConfig, + ]) => all([ servers.map((s) => ({ region: s.region, url: s.server!.url })), imageOptimizer?.url, @@ -1440,6 +1773,17 @@ async function handler(event) { ? { host: new URL(imageOptimizerUrl!).host, route: plan.imageOptimizer!.prefix, + ...(protectionConfig.mode === "oac" || + protectionConfig.mode === "oac-with-edge-signing" + ? { + originAccessControlConfig: { + enabled: true, + signingBehavior: "always", + signingProtocol: "sigv4", + originType: "lambda", + }, + } + : {}), } : undefined, servers: servers.map((s) => [ @@ -1451,6 +1795,17 @@ async function handler(event) { timeouts: { readTimeout: toSeconds(timeout), }, + ...(protectionConfig.mode === "oac" || + protectionConfig.mode === "oac-with-edge-signing" + ? { + originAccessControlConfig: { + enabled: true, + signingBehavior: "always", + signingProtocol: "sigv4", + originType: "lambda", + }, + } + : {}), }, } satisfies KV_SITE_METADATA); return kvEntries;