diff options
| -rw-r--r-- | lib/image-worker.ts | 29 | ||||
| -rw-r--r-- | lib/image.ts | 50 | ||||
| -rw-r--r-- | lib/upload.ts | 59 |
3 files changed, 127 insertions, 11 deletions
diff --git a/lib/image-worker.ts b/lib/image-worker.ts new file mode 100644 index 00000000..d45927d1 --- /dev/null +++ b/lib/image-worker.ts @@ -0,0 +1,29 @@ +// Image conversion runs in THIS separate process, spawned per upload by +// lib/image.ts. It is the only place sharp/libvips is ever loaded. Reads the +// raw upload on stdin, writes the resized webp to stdout, exits 0. Any +// decoder bug (libheif/libwebp/libpng heap corruption, SIGSEGV, SIGILL) kills +// this process alone — the app keeps serving. 2026-09-26: a stream of corrupt +// HEIF uploads crash-looped the whole server when this ran in-process. +const sharp = require("sharp"); + +const MAX_PIXELS = 80_000_000; + +const width = Number(process.argv[2]); +if (!Number.isInteger(width) || width <= 0 || width > 4096) { + console.error("bad width"); + process.exit(2); +} + +try { + const input = Buffer.from(await new Response(Bun.stdin.stream()).arrayBuffer()); + const out = await sharp(input, { failOnError: true, limitInputPixels: MAX_PIXELS }) + .rotate() + .resize(width) + .webp() + .toBuffer(); + await Bun.write(Bun.stdout, out); + process.exit(0); +} catch (e) { + console.error(e?.message || String(e)); + process.exit(2); +} 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); + } +}; diff --git a/lib/upload.ts b/lib/upload.ts index ed06d7e0..bad9735e 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -1,31 +1,68 @@ import { createHash } from "crypto"; import { writeFileSync } from "fs"; -import { err } from "$lib/logging"; +import { db } from "$lib/db"; +import { err, warn } from "$lib/logging"; import { bail, fail } from "$lib/utils"; +import { convert } from "$lib/image"; import { fileTypeFromBuffer } from "file-type"; -const sharp = require("sharp"); +// Only the formats whose decoders we are willing to expose to arbitrary +// authenticated input. Decoding happens in a throwaway child process +// (lib/image.ts) since 2026-09-26, when a stream of corrupt HEIF files +// crash-looped the server via heap corruption inside libheif — +// but a crashing decoder is still a decoder we'd rather not run at all. +// HEIF/AVIF/TIFF/SVG/PDF never get past the sniff. The previous check let +// any image/* subtype through. +const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +// Per-account budget. Avatars/banners change a handful of times, ever; the +// fuzzer was pushing 10+ a minute. +const RATE_MAX = 10; +const RATE_WINDOW_S = 600; + +const clientIp = (req) => req.headers["cf-connecting-ip"] || req.ip; export default async (req, res) => { + const { user } = req; try { const { params: { type }, } = req; + // A `blacklist` freeze only blocks sends; the JWT still works everywhere + // else, which is how a frozen account kept crashing us. Uploads are pure + // attack surface for a frozen account, so refuse them outright. + const uname = user?.username?.toLowerCase().trim(); + const blacklisted = + (await db.sIsMember("blacklist", user.id)) || + (await db.sIsMember("blacklist", uname)); + const whitelisted = await db.sIsMember("whitelist", uname); + if (blacklisted && !whitelisted) { + warn(`UPLOAD_REJECTED frozen ${user.username} ${clientIp(req)}`); + fail("unauthorized"); + } + + const rk = `upload:rl:${user.id}`; + const n = await db.incr(rk); + if (n === 1) await db.expire(rk, RATE_WINDOW_S); + if (n > RATE_MAX) { + warn(`UPLOAD_REJECTED ratelimit ${user.username} ${clientIp(req)} ${n}`); + fail("too many uploads, try again later"); + } + const data = await req.file(); let buf = await data.toBuffer(); - const [format, ext] = (await fileTypeFromBuffer(buf)).mime.split("/"); - - if (format !== "image" && !["jpg", "jpeg", "png"].includes(ext)) + const ft = await fileTypeFromBuffer(buf); + if (!ft || !ALLOWED.has(ft.mime)) { + warn( + `UPLOAD_REJECTED type ${user.username} ${clientIp(req)} ${ft?.mime || "unknown"} ${buf.length}`, + ); fail("unsupported file type"); + } const w = type === "banner" ? 1920 : 240; - buf = await sharp(buf, { failOnError: false }) - .rotate() - .resize(w) - .webp() - .toBuffer(); + buf = await convert(buf, w); const hash = createHash("sha256").update(buf).digest("hex"); @@ -34,7 +71,7 @@ export default async (req, res) => { res.send({ hash }); } catch (e) { - err("problem uploading", e.message); + err("problem uploading", user?.username, e.message); bail(res, e.message); } }; |
