diff options
| author | Adam Soltys <[email protected]> | 2026-09-26 22:45:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-26 22:45:43 -0700 |
| commit | 0912f443cc738af7dd7093670f00420de72a095f (patch) | |
| tree | 8626fdd5f9e66d6c06765659963ac137d59b0015 /lib/image.ts | |
| parent | 56aef551bf72eb306d96b4b81b657c4a05037eb5 (diff) | |
| download | coinos-server-0912f443cc738af7dd7093670f00420de72a095f.tar.gz coinos-server-0912f443cc738af7dd7093670f00420de72a095f.zip | |
upload: decode images in a throwaway child process and tighten input checks
Malformed HEIF uploads were corrupting the heap inside libvips/libheif and
taking the whole app down with them, since sharp ran in-process. Move the
decode into a per-upload child (lib/image-worker.ts, spawned by lib/image.ts
with a hard timeout) so a decoder crash fails one request instead of the
server, and never load sharp in the main process.
Also: only accept jpeg/png/webp/gif as sniffed by file-type instead of any
image/* subtype, decode with failOnError and a pixel cap, refuse uploads
from blacklisted accounts (a freeze previously only blocked sends), and
rate-limit uploads per account.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'lib/image.ts')
| -rw-r--r-- | lib/image.ts | 50 |
1 files changed, 50 insertions, 0 deletions
diff --git a/lib/image.ts b/lib/image.ts new file mode 100644 index 00000000..80bc65c3 --- /dev/null +++ b/lib/image.ts @@ -0,0 +1,50 @@ +import { warn } from "$lib/logging"; + +// Decode + resize an image in a throwaway child process (lib/image-worker.ts) +// so a native decoder crash cannot take the app down. Resolves with the webp +// bytes; rejects with a short message on any failure (bad image, crash, +// timeout). Never import sharp in the main process — that is the whole point. +const WORKER = process.env.IMAGE_WORKER || "/home/bun/app/lib/image-worker.ts"; +const TIMEOUT_MS = 20_000; +const MAX_OUTPUT = 20_000_000; + +export const convert = async (input: Buffer, width: number): Promise<Buffer> => { + const proc = Bun.spawn([process.execPath, WORKER, String(width)], { + stdin: new Blob([input]), + stdout: "pipe", + stderr: "pipe", + }); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + }, TIMEOUT_MS); + + try { + const [out, errText, code] = await Promise.all([ + new Response(proc.stdout).arrayBuffer(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (timedOut) throw new Error("image conversion timed out"); + + if (code !== 0 || proc.signalCode) { + // Exit 2 is the worker's own "sharp rejected this file"; anything else + // (signal, Bun panic banner, exit 1) is the decoder dying — log loudly, + // this is exactly the event the isolation exists for. + const reason = errText.trim().split("\n").filter(Boolean).slice(-1)[0] || ""; + if (code !== 2) + warn(`IMAGE_WORKER_CRASH code=${code} signal=${proc.signalCode} ${reason.slice(0, 200)}`); + throw new Error(code === 2 && reason ? reason.slice(0, 200) : "image conversion failed"); + } + + if (!out.byteLength || out.byteLength > MAX_OUTPUT) + throw new Error("image conversion produced no output"); + + return Buffer.from(out); + } finally { + clearTimeout(timer); + } +}; |
