diff --git a/apps/sim/lib/uploads/server/heic-pixel-guard.test.ts b/apps/sim/lib/uploads/server/heic-pixel-guard.test.ts new file mode 100644 index 00000000000..17bca5ff544 --- /dev/null +++ b/apps/sim/lib/uploads/server/heic-pixel-guard.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + * + * The pixel ceiling in `transcodeHeicToJpeg`, tested against a stubbed decoder. + * + * Separate from `heic.test.ts` so that file keeps exercising the real WebAssembly + * decoder — mocking it there would retire the one test proving the dynamic import + * resolves. Reaching the guard for real would mean hand-building a HEVC-coded HEIF, + * which needs an encoder this repo does not ship; stubbing the declared dimensions + * tests the decision the guard actually makes. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAll, mockConvert } = vi.hoisted(() => ({ + mockAll: vi.fn(), + mockConvert: vi.fn(), +})) + +vi.mock('heic-decode', () => ({ all: mockAll, default: Object.assign(vi.fn(), { all: mockAll }) })) +vi.mock('heic-convert', () => ({ default: mockConvert })) + +import { transcodeHeicToJpeg } from '@/lib/uploads/server/heic' + +/** An ISO-BMFF `ftyp` box declaring a HEVC-coded HEIF still. */ +function heifHeader(): Buffer { + const header = Buffer.alloc(16) + header.writeUInt32BE(16, 0) + header.write('ftyp', 4, 'ascii') + header.write('heic', 8, 'ascii') + return header +} + +const MAX_TRANSCODE_INPUT_PIXELS = 100_000_000 + +/** `all()` returns live libheif handles plus the `dispose` that frees them. */ +function handles(sizes: Array<{ width: number; height: number }>) { + const dispose = vi.fn() + const list = sizes.map((size) => ({ ...size, decode: vi.fn() })) + return Object.assign(list, { dispose }) +} + +describe('transcodeHeicToJpeg pixel ceiling', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConvert.mockResolvedValue(Buffer.from('jpeg-bytes')) + }) + + it.each([ + ['refused', [{ width: 30_000, height: 30_000 }]], + ['transcoded', [{ width: 8064, height: 6048 }]], + ])('frees the decoder handles when the image is %s', async (_outcome, sizes) => { + // `all()` leaves freeing to the caller, so skipping it leaks the libheif + // context on the WebAssembly heap once per preview. + const list = handles(sizes) + mockAll.mockResolvedValue(list) + + await transcodeHeicToJpeg(heifHeader()) + + expect(list.dispose).toHaveBeenCalledTimes(1) + }) + + it('frees the decoder handles even when reading dimensions throws', async () => { + const list = handles([{ width: 100, height: 100 }]) + Object.defineProperty(list[0], 'width', { + get() { + throw new Error('handle went away') + }, + }) + mockAll.mockResolvedValue(list) + + await transcodeHeicToJpeg(heifHeader()) + + expect(list.dispose).toHaveBeenCalledTimes(1) + }) + + it('refuses a container declaring more pixels than the ceiling', async () => { + // 30000x30000 is ~900MP — the decoder would allocate ~3.4GB before the codec + // is asked for anything, so the refusal has to happen on the declared size. + mockAll.mockResolvedValue(handles([{ width: 30_000, height: 30_000 }])) + + expect(await transcodeHeicToJpeg(heifHeader())).toBeNull() + expect(mockConvert).not.toHaveBeenCalled() + }) + + it('refuses when any image in a multi-image container is oversized', async () => { + mockAll.mockResolvedValue( + handles([ + { width: 100, height: 100 }, + { width: 30_000, height: 30_000 }, + ]) + ) + + expect(await transcodeHeicToJpeg(heifHeader())).toBeNull() + expect(mockConvert).not.toHaveBeenCalled() + }) + + it('transcodes a container at the ceiling', async () => { + mockAll.mockResolvedValue( + handles([{ width: MAX_TRANSCODE_INPUT_PIXELS / 10_000, height: 10_000 }]) + ) + + expect(await transcodeHeicToJpeg(heifHeader())).toEqual(Buffer.from('jpeg-bytes')) + expect(mockConvert).toHaveBeenCalledTimes(1) + }) + + it('transcodes an ordinary phone photo', async () => { + // A 48MP iPhone still, which must stay well inside the ceiling. + mockAll.mockResolvedValue(handles([{ width: 8064, height: 6048 }])) + + expect(await transcodeHeicToJpeg(heifHeader())).toEqual(Buffer.from('jpeg-bytes')) + expect(mockConvert).toHaveBeenCalledTimes(1) + }) + + it('never asks the stubbed handle to decode', async () => { + // The whole point of `all()` over `one()`: the decision is made before the + // raster is allocated. + const list = handles([{ width: 30_000, height: 30_000 }]) + mockAll.mockResolvedValue(list) + + await transcodeHeicToJpeg(heifHeader()) + + expect(list[0].decode).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/server/heic.test.ts b/apps/sim/lib/uploads/server/heic.test.ts index c34c72f017c..97ee7774a9a 100644 --- a/apps/sim/lib/uploads/server/heic.test.ts +++ b/apps/sim/lib/uploads/server/heic.test.ts @@ -124,4 +124,51 @@ describe('transcodeHeicToJpeg', () => { // amount of type-checking establishes for a lazily loaded WebAssembly module. expect(await transcodeHeicToJpeg(ftypHeader('heic'))).toBeNull() }) + + it('reports dimensions from `all()` without decoding, which the pixel check relies on', async () => { + // The guard is only worth anything if `all()` exposes the declared size up front: + // were dimensions to move behind `decode()` (as they are on the default export), + // `width * height` would silently become NaN and the check would never reject. + // Driven with a stub libheif so the real mapping runs without a HEVC encoder. + const buildDecoder = (await import('heic-decode/lib.js')).default as (lib: unknown) => { + all: (options: { + buffer: Buffer + }) => Promise & { dispose: () => void }> + } + let decoded = false + const { all } = buildDecoder({ + ready: Promise.resolve(), + HeifDecoder: class { + decoder = { delete: () => {} } + decode() { + return [ + { + get_width: () => 30_000, + get_height: () => 20_000, + free: () => {}, + display: (target: unknown, cb: (t: unknown) => void) => { + decoded = true + cb(target) + }, + }, + ] + } + }, + }) + + const handles = await all({ buffer: ftypHeader('heic') }) + + expect(handles[0].width * handles[0].height).toBe(600_000_000) + expect(typeof handles.dispose).toBe('function') + expect(decoded).toBe(false) + }) + + it('exposes `all` as a named export, which the pixel check destructures', async () => { + // A CJS `module.exports = one; module.exports.all = all` need not surface `all` + // as a named ESM export. If it stopped doing so the pixel check would throw, + // get swallowed by the catch, and quietly stop guarding — with mocked tests + // still green. Pin the real shape. + const { all } = await import('heic-decode') + expect(typeof all).toBe('function') + }) }) diff --git a/apps/sim/lib/uploads/server/heic.ts b/apps/sim/lib/uploads/server/heic.ts index 5ebc570e24a..39f583dda61 100644 --- a/apps/sim/lib/uploads/server/heic.ts +++ b/apps/sim/lib/uploads/server/heic.ts @@ -25,11 +25,26 @@ const HEIF_BRANDS = new Set([...HEVC_HEIF_BRANDS, 'mif1', 'msf1', 'avif', 'avis' * 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. + * This bounds file size only; {@link MAX_TRANSCODE_INPUT_PIXELS} bounds the raster, + * which a small file can still declare to be enormous. */ const MAX_TRANSCODE_INPUT_BYTES = 20 * 1024 * 1024 +/** + * Pixel ceiling for the fallback decode, checked against the container's declared + * dimensions before any raster exists. + * + * Needed because the decoder allocates `width * height * 4` up front — the size is + * taken straight from the `ispe` box and the buffer is built before the codec is + * asked for anything, so a malformed file never has to decode to cost the memory. + * libheif's own default ceiling is ~1.07e9 pixels (~4.3GB as RGBA), which is far too + * loose to be the only guard. + * + * 100MP caps that allocation near 400MB and clears every phone camera — a 48MP + * iPhone still is 8064x6048. + */ +const MAX_TRANSCODE_INPUT_PIXELS = 100_000_000 + /** A real `ftyp` box holds a handful of brands; anything larger is malformed or hostile. */ const MAX_FTYP_BOX_BYTES = 512 @@ -97,6 +112,36 @@ export async function transcodeHeicToJpeg(buffer: Buffer): Promise ({ width, height })) + .find((image) => image.width * image.height > MAX_TRANSCODE_INPUT_PIXELS) + } finally { + images.dispose() + } + if (oversized) { + logger.warn('Skipped HEIC transcode above the pixel ceiling', { + width: oversized.width, + height: oversized.height, + pixels: oversized.width * oversized.height, + ceiling: MAX_TRANSCODE_INPUT_PIXELS, + bytes: buffer.length, + }) + return null + } + const convert = (await import('heic-convert')).default const jpeg = await convert({ buffer, format: 'JPEG' }) logger.info('Transcoded HEIC image', { diff --git a/apps/sim/package.json b/apps/sim/package.json index c6d1fcef3e4..b59834d953a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -169,6 +169,7 @@ "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", "heic-convert": "2.1.0", + "heic-decode": "2.1.0", "html-to-text": "^9.0.5", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", diff --git a/apps/sim/types/heic-decode.d.ts b/apps/sim/types/heic-decode.d.ts new file mode 100644 index 00000000000..a9606840007 --- /dev/null +++ b/apps/sim/types/heic-decode.d.ts @@ -0,0 +1,35 @@ +/** + * `heic-decode` ships no types. Only the surface we use is declared: `all()` + * reports each image's declared dimensions and defers the decode, which is what + * lets a caller refuse an oversized one before any raster is allocated. + */ +declare module 'heic-decode' { + interface DecodedHeifImage { + width: number + height: number + data: Uint8ClampedArray + } + + interface HeifImageHandle { + width: number + height: number + decode: () => Promise + } + + /** + * `dispose` is non-enumerable on the returned array and is NOT optional: it frees + * the image handles and the libheif context, which `all()` — unlike the default + * export — leaves to the caller. Declared required so a caller cannot forget it. + */ + interface HeifImageHandles extends Array { + dispose: () => void + } + + function decode(options: { buffer: Buffer }): Promise + + namespace decode { + function all(options: { buffer: Buffer }): Promise + } + + export = decode +} diff --git a/bun.lock b/bun.lock index 2ba2c49253a..80d2428e5b0 100644 --- a/bun.lock +++ b/bun.lock @@ -272,6 +272,7 @@ "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", "heic-convert": "2.1.0", + "heic-decode": "2.1.0", "html-to-text": "^9.0.5", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6",