aboutsummaryrefslogtreecommitdiffstats
path: root/lib/upload.ts
diff options
context:
space:
mode:
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);
}
};