From bc6d949f6b3af54cc16816a4b8b2fab97cddf81b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 15:53:42 -0700 Subject: [PATCH] fix(files): bound the HEIF fallback decode input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploads allow 100MB and prepareImageForVision runs sharp with limitInputPixels: false, so nothing upstream capped what could reach the single-threaded WebAssembly decoder. A tenant-controlled file could therefore spend unbounded CPU and memory on one read. Cap the transcode input at 20MB — generous headroom over any phone photo, which runs 1-4MB. Pixel-dimension bombs stay bounded by libheif's own security limits during parse. --- apps/sim/lib/uploads/server/heic.test.ts | 7 +++++++ apps/sim/lib/uploads/server/heic.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) 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' })