diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts index 71a7b7e69..1b0219b51 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts @@ -110,6 +110,7 @@ export class HyperframesRenderStack extends Construct { NODE_OPTIONS: "--enable-source-maps", TMPDIR: "/tmp", HYPERFRAMES_LAMBDA_CHROME_SOURCE: chromeSource, + HYPERFRAMES_RENDER_BUCKET: this.bucket.bucketName, }, }); @@ -195,6 +196,7 @@ export class HyperframesRenderStack extends Construct { const NON_RETRYABLE_PLAN = [ "FFMPEG_VERSION_MISMATCH", "PLAN_HASH_MISMATCH", + "S3_URI_NOT_ALLOWED", "BROWSER_GPU_NOT_SOFTWARE", "FONT_FETCH_FAILED", "PLAN_TOO_LARGE", @@ -204,12 +206,14 @@ export class HyperframesRenderStack extends Construct { const NON_RETRYABLE_CHUNK = [ "FFMPEG_VERSION_MISMATCH", "PLAN_HASH_MISMATCH", + "S3_URI_NOT_ALLOWED", "BROWSER_GPU_NOT_SOFTWARE", "ChromeBinaryUnavailableError", ]; const NON_RETRYABLE_ASSEMBLE = [ "FFMPEG_VERSION_MISMATCH", "PLAN_HASH_MISMATCH", + "S3_URI_NOT_ALLOWED", "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", "ChromeBinaryUnavailableError", ]; diff --git a/packages/aws-lambda/src/handler.test.ts b/packages/aws-lambda/src/handler.test.ts index c3f1521b2..5a67c2bfd 100644 --- a/packages/aws-lambda/src/handler.test.ts +++ b/packages/aws-lambda/src/handler.test.ts @@ -459,6 +459,69 @@ describe("handler dispatch", () => { }); }); +describe("handler — S3 URI allowlist (security: F-004)", () => { + let prevBucket: string | undefined; + + beforeEach(() => { + prevBucket = process.env.HYPERFRAMES_RENDER_BUCKET; + }); + + afterEach(() => { + if (prevBucket === undefined) { + delete process.env.HYPERFRAMES_RENDER_BUCKET; + } else { + process.env.HYPERFRAMES_RENDER_BUCKET = prevBucket; + } + }); + + it("rejects a plan event whose ProjectS3Uri is outside the allowed bucket", async () => { + process.env.HYPERFRAMES_RENDER_BUCKET = "good-bucket"; + const tmpRoot = makeTmpRoot(); + const s3 = new FakeS3Client(); + + const event: PlanEvent = { + Action: "plan", + ProjectS3Uri: "s3://evil-bucket/project.tar.gz", + PlanOutputS3Prefix: "s3://good-bucket/renders/abc/", + Config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, + }; + const deps = { + s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client, + tmpRoot, + skipChromeResolution: true, + }; + + await expect(handler(event, deps)).rejects.toMatchObject({ + name: "S3_URI_NOT_ALLOWED", + message: expect.stringContaining("evil-bucket"), + }); + expect(s3.ops).toHaveLength(0); + }); + + it("rejects an assemble event with a cross-bucket chunk URI", async () => { + process.env.HYPERFRAMES_RENDER_BUCKET = "good-bucket"; + const tmpRoot = makeTmpRoot(); + const s3 = new FakeS3Client(); + + const event: AssembleEvent = { + Action: "assemble", + PlanS3Uri: "s3://good-bucket/plan.tar.gz", + ChunkS3Uris: ["s3://good-bucket/chunks/0001.mp4", "s3://evil-bucket/chunks/0002.mp4"], + AudioS3Uri: null, + OutputS3Uri: "s3://good-bucket/renders/abc/output.mp4", + Format: "mp4", + }; + const deps = { + s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client, + tmpRoot, + skipChromeResolution: true, + }; + + await expect(handler(event, deps)).rejects.toMatchObject({ name: "S3_URI_NOT_ALLOWED" }); + expect(s3.ops).toHaveLength(0); + }); +}); + // ── helpers ───────────────────────────────────────────────────────────────── /** diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index 91a506def..f5bf8ed6f 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -83,6 +83,7 @@ export interface HandlerDeps { */ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise { const unwrapped = unwrapEvent(event); + validateEventS3Uris(unwrapped); primeRuntimeEnv(); // Single structured boot log line — CloudWatch Logs Insights queries // key off `event=handler_start` to grep for a specific Action / S3 URI @@ -477,6 +478,44 @@ async function downloadChunkObjects( // ── Helpers ───────────────────────────────────────────────────────────────── +/** Collect every S3 URI that the handler will touch for a given event. */ +function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] { + switch (event.Action) { + case "plan": + return [event.ProjectS3Uri, event.PlanOutputS3Prefix]; + case "renderChunk": + return [event.PlanS3Uri, event.ChunkOutputS3Prefix]; + case "assemble": + return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter( + (u): u is string => u != null, + ); + } +} + +/** + * Verify every S3 URI in the event resolves to the configured render bucket. + * Throws `S3_URI_NOT_ALLOWED` (non-retryable) when a URI targets a different + * bucket, preventing event injection from reading or writing arbitrary S3 data. + * + * Skipped when `HYPERFRAMES_RENDER_BUCKET` is unset so existing deployments + * without the env var continue to work. + */ +function validateEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): void { + const allowedBucket = process.env.HYPERFRAMES_RENDER_BUCKET?.trim(); + if (!allowedBucket) return; + + for (const uri of getEventS3Uris(event)) { + const { bucket } = parseS3Uri(uri); + if (bucket !== allowedBucket) { + const err = new Error( + `[handler] S3_URI_NOT_ALLOWED: URI ${JSON.stringify(uri)} targets bucket "${bucket}" but only "${allowedBucket}" is permitted`, + ); + err.name = "S3_URI_NOT_ALLOWED"; + throw err; + } + } +} + function pad(n: number): string { return n.toString().padStart(4, "0"); }