mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(review): worker-thread HEIC conversion with bomb guard, concurrency cap, and magic-byte routing (PR #151)
- Event-loop blockage: HEIC decode/encode (CPU-synchronous libheif WASM + jpeg-js) now runs in a per-conversion worker_threads Worker (src/web/heic-jpeg-worker.ts, spawned by heic-jpeg-converter.ts) with resourceLimits and a 30s hard timeout that terminates the worker — verified end-to-end under tsx and against compiled dist/ output with a real iPhone HEIC (event-loop max stall 52ms during conversion). - No server-side concurrency cap: conversions now acquire a slot from the existing global runWithConversionLimit() pool (document-conversion-limiter), bounding peak decode memory/CPU across simultaneous uploads. - Decompression bomb: header-declared dimensions are read via heic-decode's allocation-free `.all` path and rejected above 64MP BEFORE decode() can allocate width*height*4 bytes (a <300-byte crafted file can declare 30000x30000 = 3.6GB). Regression-tested with a crafted ISOBMFF fixture against the real heic-decode WASM (test/heic-jpeg-core.test.ts). - Mislabeled HEIC (documented Android/MIUI case): conversion now routes on ftyp magic-byte sniff of the raw buffer regardless of declared ext/Content-Type, so a HEIF uploaded as image/jpeg converts instead of 415ing; the magic-mismatch 415 only fires for genuinely unrecognized bytes. - Brand allowlist narrowed to what heic-decode's isHeic() accepts (heim/heis/hevm/hevs dropped — they could only ever fail conversion). - Converted-output size: the JPEG result is checked against MAX_PASTE_IMAGE_BYTES (jpeg-js can inflate a within-limit HEIC past the cap). - Deps: heic-convert replaced with its underlying heic-decode + jpeg-js (the wrapper could not expose the pre-decode dimension check); lockfile synced, drops pngjs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ import {
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
@@ -191,30 +192,18 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||||
return data[0] === 0x42 && data[1] === 0x4d;
|
||||
case '.heic':
|
||||
case '.heif': {
|
||||
// ISO Base Media File Format: size + "ftyp" + major brand.
|
||||
// ISO Base Media File Format: size + "ftyp" + major brand. The brand
|
||||
// list matches heic-decode's own isHeic() — accepting more brands here
|
||||
// would only route bytes into a conversion that always throws.
|
||||
if (u32be(4) !== 0x66747970) return false;
|
||||
const brand = data.subarray(8, 12).toString('ascii');
|
||||
return ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1'].includes(brand);
|
||||
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
|
||||
const { default: convert } = await import('heic-convert');
|
||||
const converted = await convert({ buffer: imageBytes, format: 'JPEG', quality: 0.92 });
|
||||
const jpegBytes = Buffer.isBuffer(converted)
|
||||
? converted
|
||||
: converted instanceof ArrayBuffer
|
||||
? Buffer.from(converted)
|
||||
: Buffer.from(converted.buffer, converted.byteOffset, converted.byteLength);
|
||||
if (!imageMagicMatchesExt(jpegBytes, '.jpg')) {
|
||||
throw new Error('HEIC conversion did not produce JPEG bytes');
|
||||
}
|
||||
return jpegBytes;
|
||||
}
|
||||
|
||||
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
||||
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
||||
// against a flood of unique IP keys.
|
||||
@@ -1889,22 +1878,12 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
|
||||
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
||||
if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||||
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
|
||||
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
|
||||
// the magic bytes do not. Log the real header so format mismatches can be
|
||||
// pinned down without a reproduce-and-guess loop. The client now
|
||||
// re-encodes images to JPEG/PNG before upload, so this should be rare.
|
||||
console.warn(
|
||||
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
|
||||
);
|
||||
reply.code(415);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
||||
}
|
||||
|
||||
if (ext === '.heic' || ext === '.heif') {
|
||||
// Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
|
||||
// galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
|
||||
// browsers that cannot decode HEIF upload the original file as-is — so a
|
||||
// HEIC payload can arrive under any declared type. Filename and
|
||||
// Content-Type are attacker-supplied anyway; only the bytes are trusted.
|
||||
if (imageMagicMatchesExt(imageBytes, '.heic')) {
|
||||
try {
|
||||
imageBytes = await convertHeicToJpeg(imageBytes);
|
||||
ext = '.jpg';
|
||||
@@ -1915,6 +1894,16 @@ export function registerSessionRoutes(
|
||||
reply.code(415);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
|
||||
}
|
||||
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||||
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
|
||||
// serve back with image/png MIME. Log the real header so format
|
||||
// mismatches can be pinned down without a reproduce-and-guess loop. The
|
||||
// client re-encodes images to JPEG/PNG before upload, so this is rare.
|
||||
console.warn(
|
||||
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
|
||||
);
|
||||
reply.code(415);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
||||
}
|
||||
|
||||
// Save to {workingDir}/.claude-images/
|
||||
|
||||
Reference in New Issue
Block a user