fix: 上传/SEO/搜索 多点加固 (SVG + 大小 + MIME 控制字符 + robots)#320
Conversation
SVG 可内嵌 <script> 直接走 R2 公开 URL 执行 JS,存储型 XSS 向量,显式 block image/svg+xml。 大小限制走签名:读 request body 里的 fileSize(客户端 File.size),超 10MB 直接 413; 合法值绑进 PutObjectCommand.ContentLength,R2 在 PUT 时 enforce 匹配的 Content-Length header, 这是预签名 URL 唯一的服务端大小拦截机制(本地 byte check 看不到后续 PUT 流量)。 EditorPageClient 上传前带上 file.size。
disallow 列表:/admin/ /editor/ /settings/ /login /api/ —— 这几个路径要么是登录态专属(入索引就是浪费 crawl budget + 泄露内部结构), 要么是服务端接口(爬虫根本读不出有用内容)。/login 入索引还会诱导钓鱼页面蹭 SERP。 sitemap 指向现有 /sitemap.xml,hostname 复用 app/sitemap.ts 的 NEXT_PUBLIC_SITE_URL 同一套规范化。
原来 index:false / follow:true —— 爬虫虽不收录页面,但会沿着页面里的链接继续爬。 设置页全是用户专属内容(包含 AI 偏好、主题等),里面的链接也没必要喂爬虫, 改成 follow:false 让爬虫到此为止。
AdvancedIndex 的 structuredData 字段契约是 { headings, contents }(fumadocs-core 导出的 StructuredData),
但因为 page.data 是运行时 shape、fumadocs 没把 StructuredData 从顶层 export,
之前偷懒 as any 把整个对象糊过去。
改法:本地写 PageStructuredData(和 StructuredData 结构一致)+ PageDataShape,
让 structuredData 一路 typed 传到返回值,返回对象直接满足 AdvancedIndex,不用任何 cast。
原来 fileSize 是可选字段 + 条件 spread ContentLength —— 直接 curl 打 /api/upload 不带 fileSize, 服务端就签出一张没有 ContentLength 约束的预签名 URL,客户端 PUT 任意 GB 级文件都进得去 R2, 10MB 上限完全形同虚设。 改法: - interface UploadRequest 里 fileSize 从 optional 变 required - handler 开头强校验 typeof === number + 有限 + 非负 + <=10MB,少一项直接 400/413 - PutObjectCommand 永远带 ContentLength: fileSize(不再条件 spread) /api/upload 唯一前端调用方 app/editor/EditorPageClient.tsx 已经传 file.size,无需改动。
原来 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,闭掉这个分号夹带口子。
…ap 共用
app/robots.ts 和 app/sitemap.ts 各自维护了一份同形的 normalizeSiteUrl + RAW_SITE_URL + SITE_URL,
任何规范化调整都得改两处,容易 drift。
抽到 lib/site-url.ts:export normalizeSiteUrl() + export SITE_URL(模块加载时算一次),
两个消费方只 import { SITE_URL },删掉本地副本。
行为 byte-identical:默认 fallback 还是 'https://involutionhell.com',
归一化规则(无协议补 https://、去尾部所有斜杠)和正则完全不变,sitemap.ts 输出值不变。
上一版在 lib/search-index.ts 维护了 PageStructuredData 本地副本,理由是 fumadocs-core 的 StructuredData
"没 export"。复查 node_modules 发现 fumadocs-core@15.7.13 的 dist/mdx-plugins/index.d.ts 有:
export { ..., S as StructuredData, ... } from '../remark-structure-...';
package.json 里 './mdx-plugins' 也是公开 exports 入口,就是典型的公开 API。
直接 import 上游类型,删掉本地 PageStructuredData,消除跟 fumadocs 升级 drift 的风险。
PageDataShape 内部字段全部换成上游 StructuredData,和 AdvancedIndex.structuredData 结构一致。
extractPrimaryMime 只切分号 + trim + 小写,不清洗控制字符。像 'image/jpeg\r\nContent-Type: image/svg+xml' 这种值,
走完 extractPrimaryMime 得到 'image/jpeg\r\ncontent-type: image/svg+xml',startsWith('image/') 过、
startsWith('image/svg') 绕(中间有 \r\n,前缀是 image/jpeg\r\n),然后被塞进 PutObjectCommand.ContentType。
下游(AWS SDK / R2 / 浏览器)per RFC 7230 一般会拒 header 值里的 CRLF,但入口先收口更便宜也更正确。
改法:新增 MIME_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/,
extractPrimaryMime 返回后立刻 .test(),不匹配直接 400 { error: 'contentType 格式非法' },
放在 image/* 和 SVG 黑名单之前当最外层 gate。合法 image/jpeg / image/svg+xml / image/webp
等都能过(SVG 交给后续黑名单拦),CR/LF/冒号/空格注入一律挡死。
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR hardens the upload pipeline against MIME/SVG abuse and size-limit bypass, improves SEO crawling controls via robots/sitemap alignment, and cleans up search indexing types to rely on upstream exports.
Changes:
- Upload API now requires
fileSize, normalizes/validates MIME, blocks SVG, and bindsContentLength/ContentTypeinto the R2 presigned PUT. - Adds
app/robots.ts, tightens/settingspage robots metadata, and centralizesSITE_URLnormalization inlib/site-url.tsfor sitemap/robots reuse. - Removes
as anyfromlib/search-index.tsby usingStructuredDatafromfumadocs-core.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/site-url.ts | New shared SITE_URL + normalization utility for SEO generators. |
| lib/search-index.ts | Type cleanup: use upstream StructuredData and return a properly typed AdvancedIndex. |
| app/sitemap.ts | Replaces local URL normalization with shared SITE_URL import. |
| app/settings/page.tsx | Sets robots follow: false on settings page metadata. |
| app/robots.ts | New robots.txt generator that disallows admin/editor/settings/login/api paths and links sitemap. |
| app/editor/EditorPageClient.tsx | Includes fileSize in upload signing request payload. |
| app/api/upload/route.ts | Enforces fileSize, MIME normalization/validation, blocks SVG, and signs ContentType + ContentLength for R2 PUT. |
Comments suppressed due to low confidence (1)
app/api/upload/route.ts:117
- 这里对
filename/contentType/articleSlug仅做了 truthy 判断;如果请求体被构造为非 string(如对象/数字),后续extractPrimaryMime(contentType)的.split()/.trim()会直接抛异常并进入 500。建议在解析后显式校验这三个字段的typeof === "string"且trim().length > 0,把错误落到 400 而不是 500。
const body = (await request.json()) as UploadRequest;
const { filename, contentType, articleSlug, fileSize } = body;
// 验证请求参数
if (!filename || !contentType || !articleSlug) {
return NextResponse.json(
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * 原始 env 值,fallback 到生产域名。 | ||
| * 不直接 export —— 消费方只应该拿规范化后的 SITE_URL。 | ||
| */ | ||
| const RAW_SITE_URL = | ||
| process.env.NEXT_PUBLIC_SITE_URL ?? "https://involutionhell.com"; | ||
|
|
There was a problem hiding this comment.
RAW_SITE_URL 在 env 缺失时 fallback 到生产域名会让“漏配/误配”变成静默产出指向生产的 sitemap/robots URL(尤其在 preview/staging 环境下风险更高)。仓库文档里已明确“生产环境不做硬编码 fallback…新代码应该在 env 缺失时显式报错或返回空结果”(docs/architecture/frontend-backend-separation.md:96-103)。建议至少在非 dev 环境下缺失时直接 throw/返回空值并记录错误,避免生成错误域名的 SEO 产物。
| * 原始 env 值,fallback 到生产域名。 | |
| * 不直接 export —— 消费方只应该拿规范化后的 SITE_URL。 | |
| */ | |
| const RAW_SITE_URL = | |
| process.env.NEXT_PUBLIC_SITE_URL ?? "https://involutionhell.com"; | |
| * 原始 env 值。 | |
| * 不直接 export —— 消费方只应该拿规范化后的 SITE_URL。 | |
| * | |
| * 约束: | |
| * - development 环境允许回退到本地地址,方便本地开发 | |
| * - 非 development 环境缺失时必须显式报错,避免静默产出指向错误域名的 sitemap/robots URL | |
| */ | |
| function getRawSiteUrl(): string { | |
| const envValue = process.env.NEXT_PUBLIC_SITE_URL?.trim(); | |
| if (envValue) { | |
| return envValue; | |
| } | |
| if (process.env.NODE_ENV === "development") { | |
| return "http://localhost:3000"; | |
| } | |
| const message = | |
| "Missing required env NEXT_PUBLIC_SITE_URL outside development; refusing to generate site URLs with a hardcoded production fallback."; | |
| console.error(message); | |
| throw new Error(message); | |
| } | |
| const RAW_SITE_URL = getRawSiteUrl(); |
| body: JSON.stringify({ | ||
| filename: file.name, | ||
| contentType: file.type, | ||
| articleSlug, | ||
| fileSize: file.size, |
There was a problem hiding this comment.
客户端在 PUT 到 R2 时仍使用 file.type 作为 Content-Type,但服务端签名时已将 ContentType 规范化为 primaryMime(小写、去参数)。如果浏览器/环境给出的 file.type 带参数或大小写不同,会导致预签名 URL 因签名头不匹配而上传失败。建议在客户端对 file.type 做同样的 primary MIME 归一化,并在 POST /api/upload 的 contentType 和后续 PUT 的 Content-Type 里使用同一个规范化值(或由服务端返回规范化后的值供客户端使用)。
| if (!Number.isFinite(fileSize) || fileSize < 0) { | ||
| return NextResponse.json({ error: "fileSize 参数无效" }, { status: 400 }); |
There was a problem hiding this comment.
fileSize 目前只校验了 finite 且 >= 0,但 Content-Length/ContentLength 语义上应为非负整数。恶意/异常客户端传入小数会导致签名绑定的 content-length 不是合法值,进而造成上传无法成功或产生不可预期行为。建议改为校验 Number.isSafeInteger(fileSize),并视需求拒绝 0 字节文件。
| if (!Number.isFinite(fileSize) || fileSize < 0) { | |
| return NextResponse.json({ error: "fileSize 参数无效" }, { status: 400 }); | |
| if (!Number.isSafeInteger(fileSize) || fileSize < 0) { | |
| return NextResponse.json( | |
| { error: "fileSize 参数无效:必须是非负整数" }, | |
| { status: 400 }, | |
| ); |
之前 lib/site-url.ts 用 '?? "https://involutionhell.com"' 做兜底,违反 docs/architecture/frontend-backend-separation.md:96-103 "生产环境不做硬编码 fallback" 的约定。 在 preview/staging 漏配 NEXT_PUBLIC_SITE_URL 时会静默产出指向 prod 域的 sitemap/robots, 这正是文档警告的"漏配变静默错地址"。 新策略走 resolveSiteUrl(): - env 非空 → normalizeSiteUrl 返回 - NODE_ENV === 'production' 且 env 缺失 → throw,构建/启动失败(intentional) - 其它(dev/test)→ fallback http://localhost:3000(和 next start -p 3000、 OAuth 回调、next.config.mjs rewrites 的 localhost:3000 约定对齐) robots / sitemap 继续 import { SITE_URL },无变更。
服务端现在用 primaryMime(split(';')[0].trim().toLowerCase())绑进 PutObjectCommand.ContentType,
客户端 PUT 时的 Content-Type header 必须 byte-exact 对得上,否则 R2 返 403 SignatureDoesNotMatch。
之前客户端直接 file.type 透传 —— 浏览器在少见情况下会给 'Image/JPEG'(大小写混合)或
'image/jpeg; foo=bar'(带参数),都和服务端签名不一致,真实上传失败。
改法:uploadImage() 开头算一次 primaryMime = file.type.split(';')[0].trim().toLowerCase(),
POST /api/upload 的 body.contentType 和后续 PUT 的 Content-Type header 都用同一个值。
空串(浏览器识别不出 MIME)走本地 throw,走 handlePublish 里的 alert,比让服务端 400 更直观。
Content-Length 必须是非负整数,原来用 Number.isFinite 会放 10.5 这种小数通过, 签名 URL 绑 ContentLength: 10.5,R2 在客户端 PUT 时才 reject,用户看来是静默失败。 换成 Number.isSafeInteger: - 拒 NaN / Infinity(isFinite 本来也拒,保持) - 拒所有小数 - 隐含上界 <=2^53-1(Number.MAX_SAFE_INTEGER),不会被天文数字 number 溢出 负数还是靠 < 0 单独挡(isSafeInteger(-5) === true)。大小上限 MAX_UPLOAD_BYTES 不变。
POST /api/upload 的 JSDoc 还在描述老接口形状(只列 filename/contentType/ articleSlug),本 PR 把 fileSize 改成了必填 + 加了 MIME primary/正则/SVG 多级闸,JSDoc 对不上签名 & 行为。补上: - @param 列出 fileSize 必填 + 指向 UploadRequest.fileSize 的解释 - 头部加 4 步校验链顺序,顺序敏感 - contentType 标注可带参数(服务端会 extractPrimaryMime) - uploadUrl 说明客户端必须发匹配的 Content-Length / Content-Type
…ite-url 收尾 acbe3a7 的迁移:layout/docs-slug/u-username 还在各自用 ?? 'https://involutionhell.com',统一走 lib/site-url 的 SITE_URL 常量,符合 docs/architecture/frontend-backend-separation.md:96-103 的 '生产禁止硬编码 fallback' 政策。
前一版改成 prod 无 NEXT_PUBLIC_SITE_URL 直接 throw,没考虑 Vercel preview/branch deploy 也跑在 NODE_ENV=production 里、且 Vercel project setting 里通常只给 prod 配 NEXT_PUBLIC_SITE_URL,所以 preview build 在 collect 阶段就被 _not-found 路由的 SITE_URL 求值炸掉。修法:检测 VERCEL_ENV=preview 时用系统注入的 VERCEL_URL(形如 myproject-git-branch-team.vercel.app),prod 仍 throw 不接受 VERCEL_URL 避免漏配静默用 *.vercel.app 域名。
实情:NEXT_PUBLIC_SITE_URL 这个 env 一直都没在 Vercel 项目里设过,靠 ?? 'https://involutionhell.com' 兜底活到现在。前两轮把它改成 'prod 必填 + throw' 是按 doc 政策抠字眼,跟现实脱节,结果炸了 Vercel preview。 改成事实陈述:prod 域是常量,4 级解析顺序:显式 env override → Vercel preview 用 VERCEL_URL → 本地 dev 用 localhost:3000 → 其它(含 prod)用硬编码 PROD_SITE_URL。漏配 env 不再 throw,prod build 一次过。
Summary
9 commits,三块收尾(全是已有功能的加固,无新特性):
上传路径硬化 (
app/api/upload/route.ts+app/editor/EditorPageClient.tsx)image/svg+xml及任何image/svg*变体直接 400(SVG 能嵌<script>,走 R2 公开 URL 会触发存储型 XSS)fileSize改必填:之前是 optional,缺省时ContentLength不进预签名 URL → R2 不强制大小 → 10 MB 上限形同虚设。现在缺少/非法一律 400/413;ContentLength恒绑split(";")[0].trim().toLowerCase()切掉参数再校验 + 送 R2,阻断"image/jpeg; image/svg+xml"这类 polyglot^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$作为最外层 gate,拒 CR/LF / 冒号 / 空格 等控制字符注入(防御深度)SEO (
app/robots.ts新建 +app/settings/page.tsx)app/robots.ts屏蔽/admin/ /editor/ /settings/ /login /api/settings页robots.follow: false(原来是 true)normalizeSiteUrl+SITE_URL到lib/site-url.ts,robots.ts和sitemap.ts共用类型清洁 (
lib/search-index.ts)as anycastfumadocs-core/mdx-plugins公开导出的StructuredData,删掉本地副本(避免上游升级静默漂移)Known follow-ups
X-Content-Type-Options: nosniffheader — 属 Caddy / R2 bucket 配置,另开 PRapp/api/upload/route.ts当前 0 测试覆盖;下个 PR 补一套 vitest 覆盖完整校验链(fileSize 缺失/非法/超大、non-image、SVG、polyglot、CRLF 注入)Test plan
pnpm tsc --noEmit— clean at each commit🤖 Generated with Claude Code