diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 397df25b..94fed112 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -21,16 +21,65 @@ interface UploadRequest { filename: string; contentType: string; articleSlug: string; + /** + * 必填:客户端上传前本地读取到的文件字节数(File.size)。 + * 服务端会: + * 1. 立刻 reject 超过 MAX_UPLOAD_BYTES 的请求(省得签名) + * 2. 把 Content-Length 绑进预签名 URL,让 R2 在上传时 enforce 大小上限 + * 客户端上传时必须带匹配的 Content-Length header,否则 R2 拒签。 + * + * 为什么必填:如果 optional,直接打 /api/upload 不带 fileSize 会让服务端 + * 签出一张没有 ContentLength 约束的 URL,10MB 上限就成了摆设(客户端可上传 GB 级文件)。 + */ + fileSize: number; } +/** + * 服务端硬上限:单次上传 10 MB。 + * 注意:因为 R2 走预签名 URL,真正的拦截必须发生在签名阶段(把 ContentLength 绑进 URL), + * 不能只在 /api/upload 这里做本地 byte check——这里根本看不到后续的 PUT 流量。 + */ +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +/** + * 从完整的 Content-Type header 值里抽出主 MIME(小写、去空白、丢掉所有参数)。 + * + * 为什么需要:类似 `"image/jpeg; image/svg+xml"` 或 `"image/jpeg; charset=utf-8"` + * 这种带参数的值,用 `startsWith("image/")` 校验会过、用 `startsWith("image/svg")` + * 拒 SVG 的黑名单又绕得掉(前缀是 `image/jpeg;`),然后原始字符串塞进 R2 的 + * ContentType 再原样回吐给浏览器,触发 MIME sniffing 把 SVG payload 执行起来。 + * 所以 SVG 黑名单匹配 + 塞给 R2 的值都必须先收敛到分号前的主 MIME。 + */ +function extractPrimaryMime(contentType: string): string { + return contentType.split(";")[0]!.trim().toLowerCase(); +} + +/** + * 严格 MIME 形状:`type/subtype`,两侧只允许 [a-z0-9.+-],首字符必须是 [a-z0-9]。 + * + * 用途:拒绝 CR/LF 及其他控制字符,防止注入被 SDK/R2/浏览器当成多个 header。 + * 虽然下游(AWS SDK / R2 / 浏览器)per RFC 7230 也会拒 header 值里的 CR/LF, + * 但入口先收口更便宜也更正确,别依赖下游任何一层。 + */ +const MIME_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/; + /** * @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片 - * @param request - NextRequest 对象,请求体包含以下字段: + * + * 校验链(顺序敏感): + * 1. x-satoken + 后端 /auth/me 鉴权 + * 2. fileSize 必填 & Number.isSafeInteger & <= MAX_UPLOAD_BYTES + * 3. contentType → extractPrimaryMime → MIME_PATTERN 正则闸 → `image/` 前缀 → SVG 黑名单 + * 4. ContentType / ContentLength 绑进预签名 URL + * + * @param request - NextRequest 对象,请求体(UploadRequest)包含以下字段: * - filename: 文件名 - * - contentType: 文件 MIME 类型 + * - contentType: 文件 MIME 类型(可带参数,服务端会抽主 MIME) * - articleSlug: 文章 slug(用于组织文件路径) + * - fileSize: 必填,文件字节数;见 UploadRequest.fileSize 注释,用于把 + * ContentLength 绑进预签名 URL 做服务端大小限制 * @returns NextResponse - 返回 JSON 对象: - * - uploadUrl: 预签名上传 URL(用于 PUT 请求) + * - uploadUrl: 预签名上传 URL(用于 PUT 请求;客户端必须发送匹配的 Content-Length / Content-Type header) * - publicUrl: 图片的公开访问 URL * - key: R2 对象键 */ @@ -70,7 +119,7 @@ export async function POST(request: NextRequest) { // 解析请求体 const body = (await request.json()) as UploadRequest; - const { filename, contentType, articleSlug } = body; + const { filename, contentType, articleSlug, fileSize } = body; // 验证请求参数 if (!filename || !contentType || !articleSlug) { @@ -80,13 +129,56 @@ export async function POST(request: NextRequest) { ); } - // 验证文件类型 - if (!contentType.startsWith("image/")) { + // 验证 fileSize 必填 + 合法(必须在签名前完成,否则 ContentLength 绑不进 URL,10MB 上限等于没有) + if (typeof fileSize !== "number") { + return NextResponse.json( + { error: "缺少必要参数:fileSize(必须是 number)" }, + { status: 400 }, + ); + } + // Content-Length 必须是非负整数。用 isSafeInteger 直接拒 NaN / Infinity / 小数 + // (原来用 isFinite 会放 10.5 之类过去,然后 R2 在 PUT 时才 reject,变成用户看来 + // 的静默失败);同时 isSafeInteger 隐含了上界(<=2^53-1),不会被过大的 number 溢出。 + if (!Number.isSafeInteger(fileSize) || fileSize < 0) { + return NextResponse.json({ error: "fileSize 参数无效" }, { status: 400 }); + } + if (fileSize > MAX_UPLOAD_BYTES) { + return NextResponse.json( + { + error: `文件过大:最大允许 ${MAX_UPLOAD_BYTES} 字节(10 MB)`, + }, + { status: 413 }, + ); + } + + // 验证文件类型: + // 1. 必须是 image/* + // 2. 显式 block image/svg+xml —— SVG 可以内嵌