diff --git a/apps/sim/lib/uploads/server/heic.test.ts b/apps/sim/lib/uploads/server/heic.test.ts index 3619dfd1dfa..e44c350a525 100644 --- a/apps/sim/lib/uploads/server/heic.test.ts +++ b/apps/sim/lib/uploads/server/heic.test.ts @@ -76,6 +76,13 @@ describe('isHeifContainer', () => { }) describe('transcodeHeicToJpeg', () => { + it('refuses to decode above the input ceiling', async () => { + // Uploads allow 100MB; without this bound a tenant could spend an unbounded + // WASM decode on a single read. + const oversized = Buffer.alloc(20 * 1024 * 1024 + 1) + expect(await transcodeHeicToJpeg(oversized)).toBeNull() + }) + it('returns null for bytes libheif cannot decode', async () => { // Also proves the dynamic `heic-convert` import resolves at runtime, which no // amount of type-checking establishes for a lazily loaded WebAssembly module. diff --git a/apps/sim/lib/uploads/server/heic.ts b/apps/sim/lib/uploads/server/heic.ts index 9ff537d284a..319a9eba79b 100644 --- a/apps/sim/lib/uploads/server/heic.ts +++ b/apps/sim/lib/uploads/server/heic.ts @@ -24,6 +24,18 @@ const HEIF_BRANDS = new Set([ 'avis', ]) +/** + * Byte ceiling for a fallback decode. Uploads allow 100MB and the vision path runs + * sharp with `limitInputPixels: false`, so without this a tenant could push an + * arbitrarily large HEIF through a single-threaded WebAssembly decode. 20MB leaves + * generous headroom over any phone photo — a 12MP iPhone HEIC is 1-4MB — while + * bounding what one read can cost. + * + * This bounds file size, not pixel count. A small file declaring enormous + * dimensions is rejected during parse by libheif's own security limits. + */ +const MAX_TRANSCODE_INPUT_BYTES = 20 * 1024 * 1024 + /** * Whether these bytes are an ISO-BMFF container in the HEIF family. * @@ -58,6 +70,14 @@ export function isHeifContainer(buffer: Buffer): boolean { * Returns `null` when the bytes cannot be decoded; never a partial image. */ export async function transcodeHeicToJpeg(buffer: Buffer): Promise { + if (buffer.length > MAX_TRANSCODE_INPUT_BYTES) { + logger.warn('Skipped HEIC transcode above the input ceiling', { + bytes: buffer.length, + ceiling: MAX_TRANSCODE_INPUT_BYTES, + }) + return null + } + try { const convert = (await import('heic-convert')).default const jpeg = await convert({ buffer, format: 'JPEG' })