fix(aws-lambda): validate event S3 URIs against render bucket (F-004) (#1213)

## Summary

- Adds `validateEventS3Uris()`, called immediately after `unwrapEvent()` in the Lambda handler before any S3 I/O.
- If `HYPERFRAMES_RENDER_BUCKET` env var is set, every S3 URI in the event (`ProjectS3Uri`, `PlanOutputS3Prefix`, `PlanS3Uri`, `ChunkOutputS3Prefix`, `ChunkS3Uris`, `AudioS3Uri`, `OutputS3Uri`) must resolve to that bucket. Mismatches throw `S3_URI_NOT_ALLOWED`.
- Env var unset → validation skips (backwards-compatible; existing deployments without the var continue to work).
- CDK stack (`HyperframesRenderStack`) auto-wires `HYPERFRAMES_RENDER_BUCKET: this.bucket.bucketName` so new deployments are protected without manual config.
- `S3_URI_NOT_ALLOWED` added to all three `NON_RETRYABLE_*` lists in the Step Functions state machine so the state machine does not retry on this error.

## Security

**F-004 MED** — The Lambda handler accepted S3 URIs from the event payload without verifying they targeted the function's own render bucket. An attacker who could inject a crafted Step Functions execution input could route `GetObject` / `PutObject` calls to arbitrary buckets in the same AWS account, potentially exfiltrating plan data or overwriting objects in unrelated buckets.

## Test plan

- [x] `handler` rejects a `plan` event whose `ProjectS3Uri` targets a different bucket — `S3_URI_NOT_ALLOWED` thrown, zero S3 ops recorded
- [x] `handler` rejects an `assemble` event with one cross-bucket chunk URI
- [x] Validation is skipped when `HYPERFRAMES_RENDER_BUCKET` is unset (no regression for existing callers)
- [x] All 12 handler unit tests pass
This commit is contained in:
Vance Ingalls
2026-06-05 17:52:53 -07:00
committed by GitHub
parent 7a0cb085bb
commit 65888840fa
3 changed files with 106 additions and 0 deletions
+63
View File
@@ -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 ─────────────────────────────────────────────────────────────────
/**