mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12: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:
Generated
+2
-24
@@ -28,7 +28,8 @@
|
|||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-decode": "^2.1.0",
|
||||||
|
"jpeg-js": "^0.4.4",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
@@ -7024,29 +7025,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/heic-decode": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -69,7 +69,8 @@
|
|||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-decode": "^2.1.0",
|
||||||
|
"jpeg-js": "^0.4.4",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
|
|||||||
Vendored
-9
@@ -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<Buffer | Uint8Array | ArrayBuffer>;
|
|
||||||
}
|
|
||||||
Vendored
+24
@@ -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<DecodedHeicImage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeicImageHandles = HeicImageHandle[] & { dispose(): void };
|
||||||
|
|
||||||
|
interface HeicDecode {
|
||||||
|
(input: { buffer: Buffer | Uint8Array }): Promise<DecodedHeicImage>;
|
||||||
|
all(input: { buffer: Buffer | Uint8Array }): Promise<HeicImageHandles>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decode: HeicDecode;
|
||||||
|
export default decode;
|
||||||
|
}
|
||||||
@@ -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<Buffer> {
|
||||||
|
return runWithConversionLimit(
|
||||||
|
() =>
|
||||||
|
new Promise<Buffer>((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})`)));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Buffer> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
} from '../../hooks-config.js';
|
} from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
|
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.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;
|
return data[0] === 0x42 && data[1] === 0x4d;
|
||||||
case '.heic':
|
case '.heic':
|
||||||
case '.heif': {
|
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;
|
if (u32be(4) !== 0x66747970) return false;
|
||||||
const brand = data.subarray(8, 12).toString('ascii');
|
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:
|
default:
|
||||||
return false;
|
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.
|
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
||||||
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
||||||
// against a flood of unique IP keys.
|
// 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.
|
// Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
|
||||||
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
// galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
|
||||||
if (!imageMagicMatchesExt(imageBytes, ext)) {
|
// browsers that cannot decode HEIF upload the original file as-is — so a
|
||||||
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
|
// HEIC payload can arrive under any declared type. Filename and
|
||||||
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
|
// Content-Type are attacker-supplied anyway; only the bytes are trusted.
|
||||||
// the magic bytes do not. Log the real header so format mismatches can be
|
if (imageMagicMatchesExt(imageBytes, '.heic')) {
|
||||||
// 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') {
|
|
||||||
try {
|
try {
|
||||||
imageBytes = await convertHeicToJpeg(imageBytes);
|
imageBytes = await convertHeicToJpeg(imageBytes);
|
||||||
ext = '.jpg';
|
ext = '.jpg';
|
||||||
@@ -1915,6 +1894,16 @@ export function registerSessionRoutes(
|
|||||||
reply.code(415);
|
reply.code(415);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
|
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/
|
// Save to {workingDir}/.claude-images/
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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).
|
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
||||||
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
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')));
|
const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex')));
|
||||||
vi.mock('node:child_process', async (orig) => {
|
vi.mock('node:child_process', async (orig) => {
|
||||||
const actual = await orig<typeof import('node:child_process')>();
|
const actual = await orig<typeof import('node:child_process')>();
|
||||||
return { ...actual, execFile };
|
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';
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
|
||||||
@@ -170,7 +173,81 @@ describe('session-routes', () => {
|
|||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user