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/upload.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/upload.ts')
| -rw-r--r-- | lib/upload.ts | 59 |
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); } }; |
