fix(上传): 头像和题面插图按文件头认格式,不信扩展名
原来只看扩展名:改个后缀什么都能落盘,再由 /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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b105fe3cbd
commit
e17da63fc6
4 files changed
+38
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user