From bb1d16e2307419143873599a71ee46b9d791fc19 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Wed, 1 Jul 2026 09:23:17 +0000 Subject: [PATCH 1/2] feat(image): convert HEIC paste uploads to JPEG When a browser pastes an HEIC file without normalising it first, the paste-image route now converts it to JPEG server-side via heic-convert before writing to .claude-images/. Magic-byte validation confirms the output is valid JPEG. Adds type declarations for the heic-convert package. Co-authored-by: Saqeb Akhter --- package-lock.json | 51 +++++++++++++++++++++++++++++ package.json | 1 + src/types/heic-convert.d.ts | 9 ++++++ src/web/routes/session-routes.ts | 40 +++++++++++++++++++++-- test/routes/session-routes.test.ts | 52 ++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/types/heic-convert.d.ts diff --git a/package-lock.json b/package-lock.json index c27d204f..0c9246d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.8.5", + "heic-convert": "^2.1.0", "node-pty": "^1.1.0", "qrcode": "^1.5.4", "uuid": "^14.0.0", @@ -7023,6 +7024,41 @@ "node": ">= 0.4" } }, + "node_modules/heic-convert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz", + "integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==", + "license": "ISC", + "dependencies": { + "heic-decode": "^2.0.0", + "jpeg-js": "^0.4.4", + "pngjs": "^6.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/heic-convert/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/heic-decode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", + "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", + "license": "ISC", + "dependencies": { + "libheif-js": "^1.19.8" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -7481,6 +7517,12 @@ "node": ">=10" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -7664,6 +7706,15 @@ "node": ">= 0.8.0" } }, + "node_modules/libheif-js": { + "version": "1.19.8", + "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz", + "integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==", + "license": "LGPL-3.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", diff --git a/package.json b/package.json index d610ae58..d7e886f2 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.8.5", + "heic-convert": "^2.1.0", "node-pty": "^1.1.0", "qrcode": "^1.5.4", "uuid": "^14.0.0", diff --git a/src/types/heic-convert.d.ts b/src/types/heic-convert.d.ts new file mode 100644 index 00000000..132df724 --- /dev/null +++ b/src/types/heic-convert.d.ts @@ -0,0 +1,9 @@ +declare module 'heic-convert' { + export interface ConvertOptions { + buffer: Buffer; + format: 'JPEG' | 'PNG'; + quality?: number; + } + + export default function convert(options: ConvertOptions): Promise; +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 659731ad..fd7bd0d6 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -189,11 +189,32 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean { return u32be(0) === 0x52494646 && u32be(8) === 0x57454250; case '.bmp': return data[0] === 0x42 && data[1] === 0x4d; + case '.heic': + case '.heif': { + // ISO Base Media File Format: size + "ftyp" + major brand. + 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); + } default: return false; } } +async function convertHeicToJpeg(imageBytes: Buffer): Promise { + 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. @@ -1753,7 +1774,7 @@ export function registerSessionRoutes( // Paste Image (clipboard / drag-drop upload) // ═══════════════════════════════════════════════════════════════ - const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']); + const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']); // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts). app.post('/api/sessions/:id/paste-image', async (req, reply) => { @@ -1845,7 +1866,7 @@ export function registerSessionRoutes( const origExt = extname(part.filename).toLowerCase(); if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt; } - const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/); + const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/); if (mimeMatch) { const map: Record = { png: '.png', @@ -1854,6 +1875,8 @@ export function registerSessionRoutes( webp: '.webp', gif: '.gif', bmp: '.bmp', + heic: '.heic', + heif: '.heif', }; ext = map[mimeMatch[1]] ?? ext; } @@ -1881,6 +1904,19 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`); } + if (ext === '.heic' || ext === '.heif') { + try { + imageBytes = await convertHeicToJpeg(imageBytes); + ext = '.jpg'; + } catch (err: unknown) { + console.warn( + `[paste-image] HEIC conversion failed: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} error=${getErrorMessage(err)}` + ); + reply.code(415); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG'); + } + } + // Save to {workingDir}/.claude-images/ // Refuse symlinks at imageDir — an agent or postinstall script could plant // `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir. diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 7f0d8a39..47f44490 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -16,16 +16,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; +import { join } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); +const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex'))); vi.mock('node:child_process', async (orig) => { const actual = await orig(); return { ...actual, execFile }; }); +vi.mock('heic-convert', () => ({ default: heicConvert })); import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; @@ -45,6 +51,9 @@ async function createEnvelopeHarness( ): Promise { const app = Fastify({ logger: false }); await app.register(fastifyCookie); + await app.register(fastifyMultipart, { + limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 }, + }); const ctx = createMockRouteContext(); registerFn(app, ctx); @@ -122,6 +131,49 @@ describe('session-routes', () => { }); }); + // ========== POST /api/sessions/:id/paste-image ========== + + describe('POST /api/sessions/:id/paste-image', () => { + function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer { + return Buffer.concat([ + Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="image"; filename="${filename}"\r\n` + + `Content-Type: ${mimetype}\r\n\r\n` + ), + imageBytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + } + + it('converts HEIC paste images to JPEG attachments when browser-side normalization falls back', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-')); + harness.ctx._session.workingDir = workDir; + heicConvert.mockClear(); + + const boundary = 'codeman-test-boundary'; + const heic = Buffer.from('00000034667479706865696300000000', 'hex'); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/paste-image`, + headers: { + host: 'codeman.test', + origin: 'http://codeman.test', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: imageUploadBody(boundary, 'IMG_4996.HEIC', 'image/heic', heic), + }); + + await rm(workDir, { recursive: true }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/); + expect(heicConvert).toHaveBeenCalledWith({ buffer: heic, format: 'JPEG', quality: 0.92 }); + }); + }); + // ========== GET /api/sessions ========== describe('GET /api/sessions', () => { From 309959be27a19093158714c37b96289bd5e6a99e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 12 Jul 2026 18:06:59 +0200 Subject: [PATCH 2/2] fix(review): worker-thread HEIC conversion with bomb guard, concurrency cap, and magic-byte routing (PR #151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 26 +----- package.json | 3 +- src/types/heic-convert.d.ts | 9 --- src/types/heic-decode.d.ts | 24 ++++++ src/web/heic-jpeg-converter.ts | 82 +++++++++++++++++++ src/web/heic-jpeg-worker.ts | 93 +++++++++++++++++++++ src/web/routes/session-routes.ts | 53 +++++------- test/heic-jpeg-core.test.ts | 125 +++++++++++++++++++++++++++++ test/routes/session-routes.test.ts | 81 ++++++++++++++++++- 9 files changed, 428 insertions(+), 68 deletions(-) delete mode 100644 src/types/heic-convert.d.ts create mode 100644 src/types/heic-decode.d.ts create mode 100644 src/web/heic-jpeg-converter.ts create mode 100644 src/web/heic-jpeg-worker.ts create mode 100644 test/heic-jpeg-core.test.ts diff --git a/package-lock.json b/package-lock.json index 0c9246d7..fc2ac175 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.8.5", - "heic-convert": "^2.1.0", + "heic-decode": "^2.1.0", + "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", "uuid": "^14.0.0", @@ -7024,29 +7025,6 @@ "node": ">= 0.4" } }, - "node_modules/heic-convert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz", - "integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==", - "license": "ISC", - "dependencies": { - "heic-decode": "^2.0.0", - "jpeg-js": "^0.4.4", - "pngjs": "^6.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/heic-convert/node_modules/pngjs": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", - "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", - "license": "MIT", - "engines": { - "node": ">=12.13.0" - } - }, "node_modules/heic-decode": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", diff --git a/package.json b/package.json index d7e886f2..e9dbafff 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.8.5", - "heic-convert": "^2.1.0", + "heic-decode": "^2.1.0", + "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", "uuid": "^14.0.0", diff --git a/src/types/heic-convert.d.ts b/src/types/heic-convert.d.ts deleted file mode 100644 index 132df724..00000000 --- a/src/types/heic-convert.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module 'heic-convert' { - export interface ConvertOptions { - buffer: Buffer; - format: 'JPEG' | 'PNG'; - quality?: number; - } - - export default function convert(options: ConvertOptions): Promise; -} diff --git a/src/types/heic-decode.d.ts b/src/types/heic-decode.d.ts new file mode 100644 index 00000000..d2db29dd --- /dev/null +++ b/src/types/heic-decode.d.ts @@ -0,0 +1,24 @@ +declare module 'heic-decode' { + export interface DecodedHeicImage { + width: number; + height: number; + data: Uint8ClampedArray; + } + + /** Handle exposing header-declared dimensions WITHOUT decoding pixels. */ + export interface HeicImageHandle { + width: number; + height: number; + decode(): Promise; + } + + export type HeicImageHandles = HeicImageHandle[] & { dispose(): void }; + + interface HeicDecode { + (input: { buffer: Buffer | Uint8Array }): Promise; + all(input: { buffer: Buffer | Uint8Array }): Promise; + } + + const decode: HeicDecode; + export default decode; +} diff --git a/src/web/heic-jpeg-converter.ts b/src/web/heic-jpeg-converter.ts new file mode 100644 index 00000000..45fdaee6 --- /dev/null +++ b/src/web/heic-jpeg-converter.ts @@ -0,0 +1,82 @@ +/** + * @fileoverview Main-thread wrapper for HEIC/HEIF → JPEG conversion. + * + * The actual decode/encode (`heic-jpeg-worker.ts`) is CPU-synchronous WASM + JS, + * so it runs in a dedicated `worker_threads` Worker per conversion — never on + * the event loop that serves every session's SSE/PTY/WS traffic. On top of + * the worker isolation this wrapper enforces: + * - the global converter concurrency cap (`runWithConversionLimit`, shared + * with the pdftoppm/soffice document converters) so N simultaneous uploads + * can't pin N cores / N × 256MB decode buffers at once; + * - a hard timeout that terminates the worker (a wedged WASM decode can't be + * cancelled cooperatively); + * - the paste-image size cap on the *output* — jpeg-js is a far less + * efficient encoder than HEVC, so a within-limit HEIC can inflate past + * MAX_PASTE_IMAGE_BYTES. + */ + +import { Worker } from 'node:worker_threads'; +import { runWithConversionLimit } from '../document-conversion-limiter.js'; +import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js'; +import { HEIC_JPEG_QUALITY, type HeicWorkerInput, type HeicWorkerResult } from './heic-jpeg-worker.js'; + +/** Hard cap on a single conversion; the worker is terminated when it fires. */ +export const HEIC_CONVERSION_TIMEOUT_MS = 30_000; + +// V8-heap guardrails for the conversion worker — defense in depth only: large +// TypedArray/WASM backing stores are external to the V8 heap, so the real +// memory bound is the 64MP dimension pre-check in heic-jpeg-worker.ts. +const WORKER_RESOURCE_LIMITS = { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 128, stackSizeMb: 8 }; + +function workerUrl(): URL { + // Compiled installs run the tsc-emitted .js sibling in dist/; dev under tsx + // runs the .ts source directly (tsx's loader propagates to worker threads). + const file = import.meta.url.endsWith('.ts') ? './heic-jpeg-worker.ts' : './heic-jpeg-worker.js'; + return new URL(file, import.meta.url); +} + +/** + * Convert HEIC/HEIF bytes to JPEG bytes off-thread. Rejects on invalid input, + * over-limit dimensions, oversized output, timeout, or worker failure. + */ +export async function convertHeicToJpeg(imageBytes: Buffer): Promise { + return runWithConversionLimit( + () => + new Promise((resolve, reject) => { + const worker = new Worker(workerUrl(), { + workerData: { heicInput: imageBytes, quality: HEIC_JPEG_QUALITY } satisfies HeicWorkerInput, + resourceLimits: WORKER_RESOURCE_LIMITS, + }); + let settled = false; + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + void worker.terminate(); + }; + const timer = setTimeout(() => { + settle(() => reject(new Error(`HEIC conversion timed out after ${HEIC_CONVERSION_TIMEOUT_MS}ms`))); + }, HEIC_CONVERSION_TIMEOUT_MS); + worker.on('message', (msg: HeicWorkerResult) => { + settle(() => { + if (!msg.ok) { + reject(new Error(msg.error)); + return; + } + const out = Buffer.from(msg.data.buffer, msg.data.byteOffset, msg.data.byteLength); + if (out.length > MAX_PASTE_IMAGE_BYTES) { + const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024)); + reject(new Error(`converted JPEG (${out.length} bytes) exceeds the ${maxMb}MB upload limit`)); + return; + } + resolve(out); + }); + }); + worker.on('error', (err) => settle(() => reject(err))); + worker.on('exit', (code) => { + settle(() => reject(new Error(`HEIC conversion worker exited unexpectedly (code ${code})`))); + }); + }) + ); +} diff --git a/src/web/heic-jpeg-worker.ts b/src/web/heic-jpeg-worker.ts new file mode 100644 index 00000000..bd014fe4 --- /dev/null +++ b/src/web/heic-jpeg-worker.ts @@ -0,0 +1,93 @@ +/** + * @fileoverview HEIC/HEIF → JPEG conversion core + worker-thread entry. + * + * Spawned per conversion by `heic-jpeg-converter.ts` so the CPU-synchronous + * libheif WASM decode + jpeg-js encode never run on the server's main thread + * (on the event loop they would freeze every session's SSE/PTY/WS handling + * for seconds per photo). Input arrives via `workerData`; the result (or + * error message) is posted back as a single message and the thread exits. + * + * The conversion logic lives in this same file (exported, guarded bootstrap) + * rather than a sibling module: the worker runs from `.ts` source under tsx + * in dev, where relative `.js` imports don't resolve inside worker threads — + * only `node:` builtins are imported at top level. Unit tests import + * `convertHeicBufferToJpeg` directly; the bootstrap only runs when spawned + * with our `workerData` shape. + * + * Decompression-bomb guard: heic-decode's `.all` path exposes the + * header-declared {width, height} per image WITHOUT decoding pixels, while + * its plain decode path allocates `width * height * 4` bytes straight from + * those header values — a <1KB crafted file declaring 30000×30000 would + * demand a 3.6GB allocation. We reject anything above MAX_HEIC_DECODE_PIXELS + * before calling `decode()`. + */ + +import { parentPort, workerData } from 'node:worker_threads'; + +/** Max header-declared pixel count we will decode (64MP ≈ 256MB RGBA). */ +export const MAX_HEIC_DECODE_PIXELS = 64_000_000; + +/** JPEG quality used for converted HEIC uploads (matches heic-convert's default). */ +export const HEIC_JPEG_QUALITY = 0.92; + +export interface HeicWorkerInput { + heicInput: Uint8Array; + quality: number; +} + +export type HeicWorkerResult = { ok: true; data: Uint8Array } | { ok: false; error: string }; + +/** + * Convert HEIC/HEIF bytes to JPEG bytes. Throws on non-HEIC input, empty + * containers, over-limit dimensions, and non-JPEG encoder output. + */ +export async function convertHeicBufferToJpeg(input: Uint8Array, quality: number = HEIC_JPEG_QUALITY): Promise { + const { default: decode } = await import('heic-decode'); + const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input.buffer, input.byteOffset, input.byteLength); + const images = await decode.all({ buffer }); + try { + if (images.length === 0) throw new Error('no image found in HEIC container'); + const { width, height } = images[0]; + if ( + !Number.isSafeInteger(width) || + !Number.isSafeInteger(height) || + width <= 0 || + height <= 0 || + width * height > MAX_HEIC_DECODE_PIXELS + ) { + throw new Error( + `HEIC dimensions ${width}x${height} exceed the ${Math.floor(MAX_HEIC_DECODE_PIXELS / 1_000_000)}MP decode limit` + ); + } + const decoded = await images[0].decode(); + const { encode } = await import('jpeg-js'); + // Same output path as heic-convert's JPEG format (jpeg-js at quality*100). + const jpeg = encode( + { data: decoded.data, width: decoded.width, height: decoded.height }, + Math.floor(quality * 100) + ).data; + const jpegBytes = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg); + if (jpegBytes.length < 3 || jpegBytes[0] !== 0xff || jpegBytes[1] !== 0xd8 || jpegBytes[2] !== 0xff) { + throw new Error('HEIC conversion did not produce JPEG bytes'); + } + return jpegBytes; + } finally { + images.dispose(); + } +} + +// ── Worker bootstrap ────────────────────────────────────────────────────── +// Runs only when spawned by heic-jpeg-converter.ts: requires a parent port +// AND our exact workerData shape, so importing this module from the main +// thread (or a test runner's own worker pool) stays inert. +const request = workerData as HeicWorkerInput | null | undefined; +if (parentPort && request && request.heicInput instanceof Uint8Array && typeof request.quality === 'number') { + const port = parentPort; + try { + const jpegBytes = await convertHeicBufferToJpeg(request.heicInput, request.quality); + port.postMessage({ ok: true, data: jpegBytes } satisfies HeicWorkerResult); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + port.postMessage({ ok: false, error } satisfies HeicWorkerResult); + } +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index fd7bd0d6..d429ca63 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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 { - 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/ diff --git a/test/heic-jpeg-core.test.ts b/test/heic-jpeg-core.test.ts new file mode 100644 index 00000000..2a20b113 --- /dev/null +++ b/test/heic-jpeg-core.test.ts @@ -0,0 +1,125 @@ +/** + * @fileoverview Tests for the HEIC → JPEG conversion core (heic-jpeg-worker.ts). + * + * Exercises the REAL heic-decode WASM parse path (no mocks) for the + * decompression-bomb guard: a crafted <300-byte HEIC can declare arbitrary + * dimensions in its `ispe` box, and heic-decode's plain decode path allocates + * `width * height * 4` bytes straight from those header values (30000×30000 → + * a 3.6GB allocation). The guard must reject via the allocation-free `.all` + * dimension read BEFORE decode(). + * + * Port: N/A (pure module test, no server). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { convertHeicBufferToJpeg, MAX_HEIC_DECODE_PIXELS } from '../src/web/heic-jpeg-worker.js'; + +// ── Minimal ISOBMFF/HEIF builder — just enough boxes (ftyp/meta/hdlr/pitm/ +// iloc/iinf/iprp[hvcC+ispe]/mdat) for libheif to parse the image handle and +// report the ispe-declared dimensions. There is no real HEVC bitstream, so +// pixel decode of these files always fails — which is the point: the guard +// must fire before any decode is attempted. + +function box(type: string, ...payloads: (Buffer | string)[]): Buffer { + const payload = Buffer.concat(payloads.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p)))); + const header = Buffer.alloc(8); + header.writeUInt32BE(8 + payload.length, 0); + header.write(type, 4, 'ascii'); + return Buffer.concat([header, payload]); +} + +function fullbox(type: string, version: number, flags: number, ...payloads: (Buffer | string)[]): Buffer { + const vf = Buffer.alloc(4); + vf.writeUInt32BE((version << 24) | flags, 0); + return box(type, vf, ...payloads); +} + +function u16(n: number): Buffer { + const b = Buffer.alloc(2); + b.writeUInt16BE(n, 0); + return b; +} + +function u32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** Craft a HEIC container whose header declares `width`×`height`. */ +function craftHeic(width: number, height: number): Buffer { + const ftyp = box('ftyp', 'heic', u32(0), 'mif1heic'); + const hdlr = fullbox('hdlr', 0, 0, u32(0), 'pict', u32(0), u32(0), u32(0), Buffer.from([0])); + const pitm = fullbox('pitm', 0, 0, u16(1)); + const infe = fullbox('infe', 2, 0, u16(1), u16(0), 'hvc1', Buffer.from([0])); + const iinf = fullbox('iinf', 0, 0, u16(1), infe); + const ispe = fullbox('ispe', 0, 0, u32(width), u32(height)); + // Minimal HEVCDecoderConfigurationRecord (23 bytes, zero parameter-set arrays). + const hvcC = box( + 'hvcC', + Buffer.from([ + 0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5d, 0xf0, 0x00, 0xfc, 0xfd, 0xf8, 0xf8, 0x00, + 0x00, 0x03, 0x00, + ]), + Buffer.from([0x00]) + ); + const ipco = box('ipco', hvcC, ispe); + const ipma = fullbox('ipma', 0, 0, u32(1), u16(1), Buffer.from([2]), Buffer.from([0x81, 0x02])); + const iprp = box('iprp', ipco, ipma); + // iloc v0: offset_size=4, length_size=4, base_offset_size=0; one extent in mdat. + const ilocItem = Buffer.concat([u16(1), u16(0), u16(1), u32(0), u32(16)]); + const iloc = fullbox('iloc', 0, 0, Buffer.from([0x44, 0x00]), u16(1), ilocItem); + const meta = fullbox('meta', 0, 0, hdlr, pitm, iloc, iinf, iprp); + const mdat = box('mdat', Buffer.alloc(16)); + return Buffer.concat([ftyp, meta, mdat]); +} + +describe('heic-jpeg-core', () => { + it('rejects a crafted bomb header (30000×30000 declared, 3.6GB decode) before decoding', async () => { + const bomb = craftHeic(30000, 30000); + expect(bomb.length).toBeLessThan(1024); // tiny input, huge declared output + await expect(convertHeicBufferToJpeg(bomb)).rejects.toThrow(/30000x30000 exceed the 64MP decode limit/); + }); + + it('rejects dimensions just over the cap', async () => { + // 8000×8001 = 64,008,000 px — barely over MAX_HEIC_DECODE_PIXELS (64MP). + expect(8000 * 8001).toBeGreaterThan(MAX_HEIC_DECODE_PIXELS); + await expect(convertHeicBufferToJpeg(craftHeic(8000, 8001))).rejects.toThrow(/decode limit/); + }); + + it('lets dimensions under the cap through the guard (failure, if any, comes from pixel decode)', async () => { + // The crafted file has no real HEVC bitstream, so decode fails — but NOT + // with the dimension-limit error, proving the guard ran and passed. + await expect(convertHeicBufferToJpeg(craftHeic(100, 100))).rejects.toThrow(/^(?!.*decode limit).*$/); + }); + + it('rejects non-HEIC bytes', async () => { + await expect(convertHeicBufferToJpeg(Buffer.from('this is definitely not a HEIC image'))).rejects.toThrow( + /not a HEIC image/i + ); + }); + + it('encodes decoded RGBA into JPEG bytes with valid magic (heic-decode mocked, real jpeg-js)', async () => { + const dispose = vi.fn(); + const handle = { + width: 2, + height: 2, + decode: async () => ({ width: 2, height: 2, data: new Uint8ClampedArray(16).fill(128) }), + }; + vi.doMock('heic-decode', () => { + const decode = Object.assign(async () => handle.decode(), { + all: async () => Object.assign([handle], { dispose }), + }); + return { default: decode }; + }); + try { + const jpeg = await convertHeicBufferToJpeg(Buffer.from('mock input')); + expect(jpeg[0]).toBe(0xff); + expect(jpeg[1]).toBe(0xd8); + expect(jpeg[2]).toBe(0xff); + expect(dispose).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock('heic-decode'); + } + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 47f44490..017261c2 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -26,12 +26,15 @@ import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); +// The real converter spawns a worker thread (TS worker file — not loadable +// under vitest); the conversion pipeline itself is covered by +// test/heic-jpeg-core.test.ts against the real heic-decode WASM. const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex'))); vi.mock('node:child_process', async (orig) => { const actual = await orig(); return { ...actual, execFile }; }); -vi.mock('heic-convert', () => ({ default: heicConvert })); +vi.mock('../../src/web/heic-jpeg-converter.js', () => ({ convertHeicToJpeg: heicConvert })); import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; @@ -170,7 +173,81 @@ describe('session-routes', () => { const body = JSON.parse(res.body); expect(body.success).toBe(true); expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/); - expect(heicConvert).toHaveBeenCalledWith({ buffer: heic, format: 'JPEG', quality: 0.92 }); + expect(heicConvert).toHaveBeenCalledWith(heic); + }); + + it('converts mislabeled HEIC (declared image/jpeg, HEIF bytes — the MIUI/Android case) via magic sniff', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-mislabel-')); + harness.ctx._session.workingDir = workDir; + heicConvert.mockClear(); + + const boundary = 'codeman-test-boundary'; + // ftyp brand mif1 — HEIF bytes hiding under a JPEG filename + MIME. + const heic = Buffer.from('000000346674797061696631000000006d69663168656963', 'hex'); + heic.write('mif1', 8, 'ascii'); // major brand + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/paste-image`, + headers: { + host: 'codeman.test', + origin: 'http://codeman.test', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: imageUploadBody(boundary, 'IMG_2001.jpg', 'image/jpeg', heic), + }); + + await rm(workDir, { recursive: true }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/); + expect(heicConvert).toHaveBeenCalledWith(heic); + }); + + it('returns 415 with the error envelope when HEIC conversion fails', async () => { + heicConvert.mockClear(); + heicConvert.mockRejectedValueOnce(new Error('HEIC dimensions 30000x30000 exceed the 64MP decode limit')); + + const boundary = 'codeman-test-boundary'; + const heic = Buffer.from('00000034667479706865696300000000', 'hex'); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/paste-image`, + headers: { + host: 'codeman.test', + origin: 'http://codeman.test', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: imageUploadBody(boundary, 'IMG_4997.HEIC', 'image/heic', heic), + }); + + expect(res.statusCode).toBe(415); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.errorCode).toBe('INVALID_INPUT'); + expect(body.error).toMatch(/HEIC/); + }); + + it('rejects ftyp brands heic-decode cannot convert (e.g. heim) without invoking the converter', async () => { + heicConvert.mockClear(); + + const boundary = 'codeman-test-boundary'; + const heim = Buffer.from('00000034667479706865696d00000000', 'hex'); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/paste-image`, + headers: { + host: 'codeman.test', + origin: 'http://codeman.test', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: imageUploadBody(boundary, 'IMG_4998.HEIC', 'image/heic', heim), + }); + + expect(res.statusCode).toBe(415); + expect(JSON.parse(res.body).success).toBe(false); + expect(heicConvert).not.toHaveBeenCalled(); }); });