mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+35
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user