aboutsummaryrefslogtreecommitdiffstats
path: root/lib/upload.ts
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-26 22:45:43 -0700
committerAdam Soltys <[email protected]>2026-09-26 22:45:43 -0700
commit0912f443cc738af7dd7093670f00420de72a095f (patch)
tree8626fdd5f9e66d6c06765659963ac137d59b0015 /lib/upload.ts
parent56aef551bf72eb306d96b4b81b657c4a05037eb5 (diff)
downloadcoinos-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/upload.ts')
-rw-r--r--lib/upload.ts59
1 files changed, 48 insertions, 11 deletions
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);
}
};