Skip to content

Commit b0a5a53

Browse files
longsizhuogithub-actions[bot]
authored andcommitted
fix(upload): 提取 primary MIME,阻断分号夹带绕过 SVG 黑名单
原来 normalizedType = contentType.toLowerCase().trim() 只做大小写/首尾空白归一化,没切分号后的参数。 攻击面:"image/jpeg; image/svg+xml" 这种值,startsWith("image/") 过、 startsWith("image/svg") 拒(因为前缀是 image/jpeg;),然后原始 contentType 被塞进 R2 PutObjectCommand.ContentType 落库,R2 再回吐给浏览器,浏览器 MIME-sniff 成 SVG 把 payload 当脚本跑起来,SVG 黑名单绕掉。 改法:新增 extractPrimaryMime() 只取分号前的主 MIME,所有判断(allow image/*、 deny image/svg*)和塞给 R2 的 ContentType 都走 primaryMime,闭掉这个分号夹带口子。
1 parent ea041e0 commit b0a5a53

1 file changed

Lines changed: 25 additions & 8 deletions

File tree

app/api/upload/route.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ interface UploadRequest {
4141
*/
4242
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
4343

44+
/**
45+
* 从完整的 Content-Type header 值里抽出主 MIME(小写、去空白、丢掉所有参数)。
46+
*
47+
* 为什么需要:类似 `"image/jpeg; image/svg+xml"` 或 `"image/jpeg; charset=utf-8"`
48+
* 这种带参数的值,用 `startsWith("image/")` 校验会过、用 `startsWith("image/svg")`
49+
* 拒 SVG 的黑名单又绕得掉(前缀是 `image/jpeg;`),然后原始字符串塞进 R2 的
50+
* ContentType 再原样回吐给浏览器,触发 MIME sniffing 把 SVG payload 执行起来。
51+
* 所以 SVG 黑名单匹配 + 塞给 R2 的值都必须先收敛到分号前的主 MIME。
52+
*/
53+
function extractPrimaryMime(contentType: string): string {
54+
return contentType.split(";")[0]!.trim().toLowerCase();
55+
}
56+
4457
/**
4558
* @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片
4659
* @param request - NextRequest 对象,请求体包含以下字段:
@@ -121,16 +134,17 @@ export async function POST(request: NextRequest) {
121134
// 1. 必须是 image/*
122135
// 2. 显式 block image/svg+xml —— SVG 可以内嵌 <script>,即使走 R2 公开 URL 也会在浏览器里执行 JS,
123136
// 构成存储型 XSS 向量。我们宁可让用户转成 PNG/JPG 也不放行。
124-
const normalizedType = contentType.toLowerCase().trim();
125-
if (!normalizedType.startsWith("image/")) {
137+
// 注意:所有判断都走 primaryMime(分号前的主 MIME),绕不过 `"image/jpeg; image/svg+xml"` 这种夹带。
138+
const primaryMime = extractPrimaryMime(contentType);
139+
if (!primaryMime.startsWith("image/")) {
126140
return NextResponse.json(
127141
{ error: "仅支持图片类型文件" },
128142
{ status: 400 },
129143
);
130144
}
131145
if (
132-
normalizedType === "image/svg+xml" ||
133-
normalizedType.startsWith("image/svg")
146+
primaryMime === "image/svg+xml" ||
147+
primaryMime.startsWith("image/svg")
134148
) {
135149
return NextResponse.json(
136150
{ error: "出于安全原因,不接受 SVG 文件(可能包含可执行脚本)" },
@@ -147,13 +161,16 @@ export async function POST(request: NextRequest) {
147161
const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`;
148162

149163
// 创建 PutObject 命令
150-
// ContentLength 强绑 fileSize —— 上传时客户端必须发送匹配的 Content-Length header,
151-
// R2 会 enforce,超过或少于这个数字的 PUT 一律被 R2 拒绝。
152-
// 这是预签名 URL 唯一能做服务端大小限制的机制,所以 fileSize 必须必填。
164+
// - ContentType 用 primaryMime —— 不能把原始 contentType 原样塞进 R2 对象元数据,
165+
// 否则 `"image/jpeg; image/svg+xml"` 之类的分号夹带会跟着落库,R2 回吐给浏览器时
166+
// 触发 MIME sniffing。
167+
// - ContentLength 强绑 fileSize —— 上传时客户端必须发送匹配的 Content-Length header,
168+
// R2 会 enforce,超过或少于这个数字的 PUT 一律被 R2 拒绝。
169+
// 这是预签名 URL 唯一能做服务端大小限制的机制,所以 fileSize 必须必填。
153170
const command = new PutObjectCommand({
154171
Bucket: process.env.R2_BUCKET_NAME,
155172
Key: key,
156-
ContentType: contentType,
173+
ContentType: primaryMime,
157174
ContentLength: fileSize,
158175
});
159176

0 commit comments

Comments
 (0)