@@ -21,16 +21,65 @@ interface UploadRequest {
2121 filename : string ;
2222 contentType : string ;
2323 articleSlug : string ;
24+ /**
25+ * 必填:客户端上传前本地读取到的文件字节数(File.size)。
26+ * 服务端会:
27+ * 1. 立刻 reject 超过 MAX_UPLOAD_BYTES 的请求(省得签名)
28+ * 2. 把 Content-Length 绑进预签名 URL,让 R2 在上传时 enforce 大小上限
29+ * 客户端上传时必须带匹配的 Content-Length header,否则 R2 拒签。
30+ *
31+ * 为什么必填:如果 optional,直接打 /api/upload 不带 fileSize 会让服务端
32+ * 签出一张没有 ContentLength 约束的 URL,10MB 上限就成了摆设(客户端可上传 GB 级文件)。
33+ */
34+ fileSize : number ;
2435}
2536
37+ /**
38+ * 服务端硬上限:单次上传 10 MB。
39+ * 注意:因为 R2 走预签名 URL,真正的拦截必须发生在签名阶段(把 ContentLength 绑进 URL),
40+ * 不能只在 /api/upload 这里做本地 byte check——这里根本看不到后续的 PUT 流量。
41+ */
42+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024 ;
43+
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+
57+ /**
58+ * 严格 MIME 形状:`type/subtype`,两侧只允许 [a-z0-9.+-],首字符必须是 [a-z0-9]。
59+ *
60+ * 用途:拒绝 CR/LF 及其他控制字符,防止注入被 SDK/R2/浏览器当成多个 header。
61+ * 虽然下游(AWS SDK / R2 / 浏览器)per RFC 7230 也会拒 header 值里的 CR/LF,
62+ * 但入口先收口更便宜也更正确,别依赖下游任何一层。
63+ */
64+ const MIME_PATTERN = / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 . + - ] * \/ [ a - z 0 - 9 ] [ a - z 0 - 9 . + - ] * $ / ;
65+
2666/**
2767 * @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片
28- * @param request - NextRequest 对象,请求体包含以下字段:
68+ *
69+ * 校验链(顺序敏感):
70+ * 1. x-satoken + 后端 /auth/me 鉴权
71+ * 2. fileSize 必填 & Number.isSafeInteger & <= MAX_UPLOAD_BYTES
72+ * 3. contentType → extractPrimaryMime → MIME_PATTERN 正则闸 → `image/` 前缀 → SVG 黑名单
73+ * 4. ContentType / ContentLength 绑进预签名 URL
74+ *
75+ * @param request - NextRequest 对象,请求体(UploadRequest)包含以下字段:
2976 * - filename: 文件名
30- * - contentType: 文件 MIME 类型
77+ * - contentType: 文件 MIME 类型(可带参数,服务端会抽主 MIME)
3178 * - articleSlug: 文章 slug(用于组织文件路径)
79+ * - fileSize: 必填,文件字节数;见 UploadRequest.fileSize 注释,用于把
80+ * ContentLength 绑进预签名 URL 做服务端大小限制
3281 * @returns NextResponse - 返回 JSON 对象:
33- * - uploadUrl: 预签名上传 URL(用于 PUT 请求)
82+ * - uploadUrl: 预签名上传 URL(用于 PUT 请求;客户端必须发送匹配的 Content-Length / Content-Type header )
3483 * - publicUrl: 图片的公开访问 URL
3584 * - key: R2 对象键
3685 */
@@ -70,7 +119,7 @@ export async function POST(request: NextRequest) {
70119
71120 // 解析请求体
72121 const body = ( await request . json ( ) ) as UploadRequest ;
73- const { filename, contentType, articleSlug } = body ;
122+ const { filename, contentType, articleSlug, fileSize } = body ;
74123
75124 // 验证请求参数
76125 if ( ! filename || ! contentType || ! articleSlug ) {
@@ -80,13 +129,56 @@ export async function POST(request: NextRequest) {
80129 ) ;
81130 }
82131
83- // 验证文件类型
84- if ( ! contentType . startsWith ( "image/" ) ) {
132+ // 验证 fileSize 必填 + 合法(必须在签名前完成,否则 ContentLength 绑不进 URL,10MB 上限等于没有)
133+ if ( typeof fileSize !== "number" ) {
134+ return NextResponse . json (
135+ { error : "缺少必要参数:fileSize(必须是 number)" } ,
136+ { status : 400 } ,
137+ ) ;
138+ }
139+ // Content-Length 必须是非负整数。用 isSafeInteger 直接拒 NaN / Infinity / 小数
140+ // (原来用 isFinite 会放 10.5 之类过去,然后 R2 在 PUT 时才 reject,变成用户看来
141+ // 的静默失败);同时 isSafeInteger 隐含了上界(<=2^53-1),不会被过大的 number 溢出。
142+ if ( ! Number . isSafeInteger ( fileSize ) || fileSize < 0 ) {
143+ return NextResponse . json ( { error : "fileSize 参数无效" } , { status : 400 } ) ;
144+ }
145+ if ( fileSize > MAX_UPLOAD_BYTES ) {
146+ return NextResponse . json (
147+ {
148+ error : `文件过大:最大允许 ${ MAX_UPLOAD_BYTES } 字节(10 MB)` ,
149+ } ,
150+ { status : 413 } ,
151+ ) ;
152+ }
153+
154+ // 验证文件类型:
155+ // 1. 必须是 image/*
156+ // 2. 显式 block image/svg+xml —— SVG 可以内嵌 <script>,即使走 R2 公开 URL 也会在浏览器里执行 JS,
157+ // 构成存储型 XSS 向量。我们宁可让用户转成 PNG/JPG 也不放行。
158+ // 注意:所有判断都走 primaryMime(分号前的主 MIME),绕不过 `"image/jpeg; image/svg+xml"` 这种夹带。
159+ const primaryMime = extractPrimaryMime ( contentType ) ;
160+ // 拒绝 CR/LF 及其他控制字符,防止注入被 SDK/R2/浏览器当成多个 header
161+ if ( ! MIME_PATTERN . test ( primaryMime ) ) {
162+ return NextResponse . json (
163+ { error : "contentType 格式非法" } ,
164+ { status : 400 } ,
165+ ) ;
166+ }
167+ if ( ! primaryMime . startsWith ( "image/" ) ) {
85168 return NextResponse . json (
86169 { error : "仅支持图片类型文件" } ,
87170 { status : 400 } ,
88171 ) ;
89172 }
173+ if (
174+ primaryMime === "image/svg+xml" ||
175+ primaryMime . startsWith ( "image/svg" )
176+ ) {
177+ return NextResponse . json (
178+ { error : "出于安全原因,不接受 SVG 文件(可能包含可执行脚本)" } ,
179+ { status : 400 } ,
180+ ) ;
181+ }
90182
91183 // 生成唯一的对象键
92184 // 格式:users/{userId}/{article-slug}/{timestamp}-{filename}
@@ -97,10 +189,17 @@ export async function POST(request: NextRequest) {
97189 const key = `users/${ userId } /${ sanitizedSlug } /${ timestamp } -${ sanitizedFilename } ` ;
98190
99191 // 创建 PutObject 命令
192+ // - ContentType 用 primaryMime —— 不能把原始 contentType 原样塞进 R2 对象元数据,
193+ // 否则 `"image/jpeg; image/svg+xml"` 之类的分号夹带会跟着落库,R2 回吐给浏览器时
194+ // 触发 MIME sniffing。
195+ // - ContentLength 强绑 fileSize —— 上传时客户端必须发送匹配的 Content-Length header,
196+ // R2 会 enforce,超过或少于这个数字的 PUT 一律被 R2 拒绝。
197+ // 这是预签名 URL 唯一能做服务端大小限制的机制,所以 fileSize 必须必填。
100198 const command = new PutObjectCommand ( {
101199 Bucket : process . env . R2_BUCKET_NAME ,
102200 Key : key ,
103- ContentType : contentType ,
201+ ContentType : primaryMime ,
202+ ContentLength : fileSize ,
104203 } ) ;
105204
106205 // 生成预签名 URL(15 分钟有效期)
0 commit comments