Skip to content

Commit 6b215d9

Browse files
committed
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.
1 parent 9883543 commit 6b215d9

6 files changed

Lines changed: 162 additions & 2 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* The pixel ceiling in `transcodeHeicToJpeg`, tested against a stubbed decoder.
5+
*
6+
* Separate from `heic.test.ts` so that file keeps exercising the real WebAssembly
7+
* decoder — mocking it there would retire the one test proving the dynamic import
8+
* resolves. Reaching the guard for real would mean hand-building a HEVC-coded HEIF,
9+
* which needs an encoder this repo does not ship; stubbing the declared dimensions
10+
* tests the decision the guard actually makes.
11+
*/
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockAll, mockConvert } = vi.hoisted(() => ({
15+
mockAll: vi.fn(),
16+
mockConvert: vi.fn(),
17+
}))
18+
19+
vi.mock('heic-decode', () => ({ all: mockAll, default: Object.assign(vi.fn(), { all: mockAll }) }))
20+
vi.mock('heic-convert', () => ({ default: mockConvert }))
21+
22+
import { transcodeHeicToJpeg } from '@/lib/uploads/server/heic'
23+
24+
/** An ISO-BMFF `ftyp` box declaring a HEVC-coded HEIF still. */
25+
function heifHeader(): Buffer {
26+
const header = Buffer.alloc(16)
27+
header.writeUInt32BE(16, 0)
28+
header.write('ftyp', 4, 'ascii')
29+
header.write('heic', 8, 'ascii')
30+
return header
31+
}
32+
33+
const MAX_TRANSCODE_INPUT_PIXELS = 100_000_000
34+
35+
describe('transcodeHeicToJpeg pixel ceiling', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
mockConvert.mockResolvedValue(Buffer.from('jpeg-bytes'))
39+
})
40+
41+
it('refuses a container declaring more pixels than the ceiling', async () => {
42+
// 30000x30000 is ~900MP — the decoder would allocate ~3.4GB before the codec
43+
// is asked for anything, so the refusal has to happen on the declared size.
44+
mockAll.mockResolvedValue([{ width: 30_000, height: 30_000, decode: vi.fn() }])
45+
46+
expect(await transcodeHeicToJpeg(heifHeader())).toBeNull()
47+
expect(mockConvert).not.toHaveBeenCalled()
48+
})
49+
50+
it('refuses when any image in a multi-image container is oversized', async () => {
51+
mockAll.mockResolvedValue([
52+
{ width: 100, height: 100, decode: vi.fn() },
53+
{ width: 30_000, height: 30_000, decode: vi.fn() },
54+
])
55+
56+
expect(await transcodeHeicToJpeg(heifHeader())).toBeNull()
57+
expect(mockConvert).not.toHaveBeenCalled()
58+
})
59+
60+
it('transcodes a container at the ceiling', async () => {
61+
mockAll.mockResolvedValue([
62+
{ width: MAX_TRANSCODE_INPUT_PIXELS / 10_000, height: 10_000, decode: vi.fn() },
63+
])
64+
65+
expect(await transcodeHeicToJpeg(heifHeader())).toEqual(Buffer.from('jpeg-bytes'))
66+
expect(mockConvert).toHaveBeenCalledTimes(1)
67+
})
68+
69+
it('transcodes an ordinary phone photo', async () => {
70+
// A 48MP iPhone still, which must stay well inside the ceiling.
71+
mockAll.mockResolvedValue([{ width: 8064, height: 6048, decode: vi.fn() }])
72+
73+
expect(await transcodeHeicToJpeg(heifHeader())).toEqual(Buffer.from('jpeg-bytes'))
74+
expect(mockConvert).toHaveBeenCalledTimes(1)
75+
})
76+
77+
it('never asks the stubbed handle to decode', async () => {
78+
// The whole point of `all()` over `one()`: the decision is made before the
79+
// raster is allocated.
80+
const decode = vi.fn()
81+
mockAll.mockResolvedValue([{ width: 30_000, height: 30_000, decode }])
82+
83+
await transcodeHeicToJpeg(heifHeader())
84+
85+
expect(decode).not.toHaveBeenCalled()
86+
})
87+
})

apps/sim/lib/uploads/server/heic.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,13 @@ describe('transcodeHeicToJpeg', () => {
124124
// amount of type-checking establishes for a lazily loaded WebAssembly module.
125125
expect(await transcodeHeicToJpeg(ftypHeader('heic'))).toBeNull()
126126
})
127+
128+
it('exposes `all` as a named export, which the pixel check destructures', async () => {
129+
// A CJS `module.exports = one; module.exports.all = all` need not surface `all`
130+
// as a named ESM export. If it stopped doing so the pixel check would throw,
131+
// get swallowed by the catch, and quietly stop guarding — with mocked tests
132+
// still green. Pin the real shape.
133+
const { all } = await import('heic-decode')
134+
expect(typeof all).toBe('function')
135+
})
127136
})

apps/sim/lib/uploads/server/heic.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,26 @@ const HEIF_BRANDS = new Set([...HEVC_HEIF_BRANDS, 'mif1', 'msf1', 'avif', 'avis'
2525
* generous headroom over any phone photo — a 12MP iPhone HEIC is 1-4MB — while
2626
* bounding what one read can cost.
2727
*
28-
* This bounds file size, not pixel count. A small file declaring enormous
29-
* dimensions is rejected during parse by libheif's own security limits.
28+
* This bounds file size only; {@link MAX_TRANSCODE_INPUT_PIXELS} bounds the raster,
29+
* which a small file can still declare to be enormous.
3030
*/
3131
const MAX_TRANSCODE_INPUT_BYTES = 20 * 1024 * 1024
3232

33+
/**
34+
* Pixel ceiling for the fallback decode, checked against the container's declared
35+
* dimensions before any raster exists.
36+
*
37+
* Needed because the decoder allocates `width * height * 4` up front — the size is
38+
* taken straight from the `ispe` box and the buffer is built before the codec is
39+
* asked for anything, so a malformed file never has to decode to cost the memory.
40+
* libheif's own default ceiling is ~1.07e9 pixels (~4.3GB as RGBA), which is far too
41+
* loose to be the only guard.
42+
*
43+
* 100MP caps that allocation near 400MB and clears every phone camera — a 48MP
44+
* iPhone still is 8064x6048.
45+
*/
46+
const MAX_TRANSCODE_INPUT_PIXELS = 100_000_000
47+
3348
/** A real `ftyp` box holds a handful of brands; anything larger is malformed or hostile. */
3449
const MAX_FTYP_BOX_BYTES = 512
3550

@@ -97,6 +112,27 @@ export async function transcodeHeicToJpeg(buffer: Buffer): Promise<Buffer | null
97112
}
98113

99114
try {
115+
// Read the declared dimensions first. `all()` parses the container and reports
116+
// each image's size while leaving the decode — and therefore the allocation —
117+
// for `decode()`, which is what makes refusing an oversized one cheap. The
118+
// container gets parsed twice as a result; that is a header parse against a
119+
// ceiling this path exists to enforce, and only on the HEVC fallback.
120+
const { all } = await import('heic-decode')
121+
const images = await all({ buffer })
122+
const oversized = images.find(
123+
(image) => image.width * image.height > MAX_TRANSCODE_INPUT_PIXELS
124+
)
125+
if (oversized) {
126+
logger.warn('Skipped HEIC transcode above the pixel ceiling', {
127+
width: oversized.width,
128+
height: oversized.height,
129+
pixels: oversized.width * oversized.height,
130+
ceiling: MAX_TRANSCODE_INPUT_PIXELS,
131+
bytes: buffer.length,
132+
})
133+
return null
134+
}
135+
100136
const convert = (await import('heic-convert')).default
101137
const jpeg = await convert({ buffer, format: 'JPEG' })
102138
logger.info('Transcoded HEIC image', {

apps/sim/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@
169169
"gray-matter": "^4.0.3",
170170
"groq-sdk": "^0.15.0",
171171
"heic-convert": "2.1.0",
172+
"heic-decode": "2.1.0",
172173
"html-to-text": "^9.0.5",
173174
"http-proxy-agent": "7.0.2",
174175
"https-proxy-agent": "7.0.6",

apps/sim/types/heic-decode.d.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* `heic-decode` ships no types. Only the surface we use is declared: `all()`
3+
* reports each image's declared dimensions and defers the decode, which is what
4+
* lets a caller refuse an oversized one before any raster is allocated.
5+
*/
6+
declare module 'heic-decode' {
7+
interface DecodedHeifImage {
8+
width: number
9+
height: number
10+
data: Uint8ClampedArray
11+
}
12+
13+
interface HeifImageHandle {
14+
width: number
15+
height: number
16+
decode: () => Promise<DecodedHeifImage>
17+
}
18+
19+
function decode(options: { buffer: Buffer }): Promise<DecodedHeifImage>
20+
21+
namespace decode {
22+
function all(options: { buffer: Buffer }): Promise<HeifImageHandle[]>
23+
}
24+
25+
export = decode
26+
}

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)