@@ -41,6 +41,19 @@ interface UploadRequest {
4141 */
4242const 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