mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface HandlerDeps {
|
||||
*/
|
||||
export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<LambdaResult> {
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user