Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});

Expand Down Expand Up @@ -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",
Expand All @@ -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",
];
Expand Down
63 changes: 63 additions & 0 deletions packages/aws-lambda/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────

/**
Expand Down
39 changes: 39 additions & 0 deletions packages/aws-lambda/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
Expand Down
Loading