From e17da63fc637b64920b771c28db58f6b3f5075d6 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 15:15:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E4=B8=8A=E4=BC=A0):=20=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E5=92=8C=E9=A2=98=E9=9D=A2=E6=8F=92=E5=9B=BE=E6=8C=89=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B4=E8=AE=A4=E6=A0=BC=E5=BC=8F=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E4=BF=A1=E6=89=A9=E5=B1=95=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来只看扩展名:改个后缀什么都能落盘,再由 /public/* 按扩展名配 content-type 发出去。线上 Caddy 有 nosniff 兜着,但不该把非图片存到盘上。 - services/image.ts:按 PNG / JPEG / GIF / BMP 的文件头识别,返回落盘用的扩展名 —— 以内容为准,改名成 .png 的 JPEG 存成 .jpg,content-type 才对得上 - 头像上传、后台插图上传都走它;插图那边扩展名白名单保留当第一道快速筛 - serveUpload 自己也带 x-content-type-options: nosniff,不只靠 Caddy 实跑核过:真 PNG / GIF 正常;改名成 .png 的 JPEG 存成 .jpg;改名成 .png 的 HTML 两个入口都拒;/public/upload 回 image/png + nosniff。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/index.ts | 7 ++++++- apps/api/src/routes/account.ts | 7 ++++--- apps/api/src/routes/admin/conf.ts | 9 +++++++-- apps/api/src/services/image.ts | 21 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/services/image.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 02574f5..207b1ab 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -78,7 +78,12 @@ async function serveUpload( if (await file.exists()) { // 文件名由后端生成且内容不变,可以放心长缓存 return new Response(file, { - headers: { "cache-control": "public, max-age=86400" }, + headers: { + "cache-control": "public, max-age=86400", + // 线上 Caddy 也加了这个头,这里再加一道:content-type 按扩展名配, + // 不让浏览器自己嗅探成 HTML 去执行 + "x-content-type-options": "nosniff", + }, }) } return null diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 8c0385e..c664167 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto" -import { extname, resolve } from "node:path" +import { resolve } from "node:path" import { rankProfileSchema, @@ -46,6 +46,7 @@ import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { JudgeStatus } from "../judge/status" import { getBooleanOption } from "../services/options" +import { sniffImageExtension } from "../services/image" import { getUserProfileById } from "../services/profile" import { clientIp, @@ -205,8 +206,8 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => { return failure(c, 400, "invalid-file", "Invalid file content") if (image.size > 2 * 1024 * 1024) return failure(c, 400, "file-too-large", "Picture is too large") - const extension = extname(image.name).toLowerCase() - if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) { + const extension = await sniffImageExtension(image) + if (!extension) { return failure(c, 400, "unsupported-file", "Unsupported file format") } const filename = `${randomBytes(10).toString("hex")}${extension}` diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index eb33107..4ad212e 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -23,6 +23,7 @@ import { config } from "../../config" import { db, schema } from "../../db" import { publishConfigUpdate } from "../../events" import { failure, parseBody, success } from "../../http" +import { sniffImageExtension } from "../../services/image" import { getWebsiteOptions } from "../../services/options" import { todayStart } from "../../time" import { queryInteger } from "../helpers" @@ -292,8 +293,12 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => { filePath: "", } satisfies UploadImageResponse) } - const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase() - if (!IMAGE_SUFFIXES.includes(suffix)) { + // 以文件头为准,扩展名只是第一道快速筛 + const claimed = image.name.slice(image.name.lastIndexOf(".")).toLowerCase() + const suffix = IMAGE_SUFFIXES.includes(claimed) + ? await sniffImageExtension(image) + : null + if (!suffix) { return success(c, { success: false, msg: "Unsupported file format", diff --git a/apps/api/src/services/image.ts b/apps/api/src/services/image.ts new file mode 100644 index 0000000..54f2d04 --- /dev/null +++ b/apps/api/src/services/image.ts @@ -0,0 +1,21 @@ +/** + * 按文件头认图片格式,不信文件名。头像和题面插图原来只看扩展名:改个后缀什么都能 + * 落盘,再由 /public/* 按扩展名配 content-type 发出去。 + * + * 返回值就是落盘该用的扩展名 —— 以内容为准,一张改名成 .png 的 JPEG 会存成 .jpg, + * 浏览器拿到的 content-type 才对得上。 + */ +const SIGNATURES: [extension: string, bytes: number[]][] = [ + [".png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]], + [".jpg", [0xff, 0xd8, 0xff]], + [".gif", [0x47, 0x49, 0x46, 0x38]], // GIF8(GIF87a / GIF89a) + [".bmp", [0x42, 0x4d]], // BM +] + +export async function sniffImageExtension(file: Blob) { + const head = new Uint8Array(await file.slice(0, 8).arrayBuffer()) + for (const [extension, bytes] of SIGNATURES) { + if (bytes.every((byte, index) => head[index] === byte)) return extension + } + return null +}