fix(uploads): bound the HEIF fallback decode by declared pixels, not just bytes (#6456)

* fix(uploads): bound the HEIF fallback decode by declared pixels, not just bytes

The WebAssembly fallback allocates width * height * 4 up front, taking the size
straight from the container and building the buffer before the codec is asked for
anything — so a file that never decodes still costs the memory. Only a 20MB byte
ceiling stood in front of it, and bytes do not bound a declared raster: a small
container can name dimensions up to libheif's own default of ~1.07e9 pixels, about
4.3GB as RGBA.

Read the declared dimensions with `heic-decode`'s `all()`, which parses the
container and reports each image's size while leaving the decode for `decode()`,
and refuse above 100MP. That caps the allocation near 400MB and clears every phone
camera — a 48MP iPhone still is 8064x6048.

`heic-decode` was already present as a transitive dependency of `heic-convert`;
this promotes it to a direct one at the same version, since the code now imports it.
Local types cover only the surface used, and a test pins that `all` really is a
named ESM export — a CJS `module.exports = one` need not surface it, and if it
stopped, the check would throw, get swallowed by the catch, and quietly stop
guarding with mocked tests still green.

* fix(uploads): free the HEIF decoder handles after the pixel check

all() hands back live libheif handles and leaves freeing them to the caller —
the default export does it in a finally, this path did not. Every HEIF preview
would have leaked the decoder context on the WebAssembly heap.

Copy the two numbers out, dispose in a finally so a throw mid-read still frees,
and declare dispose as required on the returned array so a caller cannot omit it.

* test(uploads): pin that all() reports dimensions before decoding

The pixel guard is only worth anything if the declared size is available up
front. Were dimensions to move behind decode() — as they already are on the
default export — width * height would silently become NaN and the check would
stop rejecting anything, with every mocked test still green.

Drives the real mapping with a stub libheif, so the contract is exercised
without needing a HEVC encoder to build a valid container.
This commit is contained in:
Waleed
2026-08-08 15:45:29 -07:00
committed by GitHub
parent 8a312a7f95
commit ff1ea218dc
6 changed files with 255 additions and 2 deletions
@@ -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()
})
})
+47
View File
@@ -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<Array<{ width: number; height: number }> & { 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')
})
})
+47 -2
View File
@@ -24,11 +24,26 @@ const HEIF_BRANDS = new Set([...HEVC_HEIF_BRANDS, 'mif1', 'msf1', 'avif', 'avis'
* 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.
* This bounds file size only; {@link MAX_TRANSCODE_INPUT_PIXELS} bounds the raster,
* which a small file can still declare to be enormous.
*/
export 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
@@ -96,6 +111,36 @@ export async function transcodeHeicToJpeg(buffer: Buffer): Promise<Buffer | null
}
try {
// Read the declared dimensions first. `all()` parses the container and reports
// each image's size while leaving the decode — and therefore the allocation —
// for `decode()`, which is what makes refusing an oversized one cheap. The
// container gets parsed twice as a result; that is a header parse against a
// ceiling this path exists to enforce, and only on the HEVC fallback.
const { all } = await import('heic-decode')
const images = await all({ buffer })
// `all()` hands back live libheif handles and, unlike the default export, leaves
// freeing them to the caller — skipping this leaks the decoder context on the
// WebAssembly heap once per preview. The dimensions are plain numbers, so they
// outlive the handles safely.
let oversized: { width: number; height: number } | undefined
try {
oversized = images
.map(({ width, height }) => ({ 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', {
+1
View File
@@ -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",
+35
View File
@@ -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<DecodedHeifImage>
}
/**
* `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<HeifImageHandle> {
dispose: () => void
}
function decode(options: { buffer: Buffer }): Promise<DecodedHeifImage>
namespace decode {
function all(options: { buffer: Buffer }): Promise<HeifImageHandles>
}
export = decode
}
+1
View File
@@ -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",