aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--lib/image-worker.ts29
-rw-r--r--lib/image.ts50
-rw-r--r--lib/upload.ts59
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);
}
};