fix(files): bound the HEIF fallback decode input (#6348)

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.
This commit is contained in:
Waleed
2026-08-06 16:05:37 -07:00
committed by GitHub
parent 5596640b3b
commit 2b35a3c9c7
2 changed files with 27 additions and 0 deletions
+7
View File
@@ -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.
+20
View File
@@ -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<Buffer | null> {
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' })