Skip to content

fix: 上传/SEO/搜索 多点加固 (SVG + 大小 + MIME 控制字符 + robots)#320

Merged
longsizhuo merged 16 commits intomainfrom
fix/security-hardening-misc
Apr 25, 2026
Merged

fix: 上传/SEO/搜索 多点加固 (SVG + 大小 + MIME 控制字符 + robots)#320
longsizhuo merged 16 commits intomainfrom
fix/security-hardening-misc

Conversation

@longsizhuo
Copy link
Copy Markdown
Member

Summary

9 commits,三块收尾(全是已有功能的加固,无新特性):

上传路径硬化 (app/api/upload/route.ts + app/editor/EditorPageClient.tsx)

  • 拒 SVGimage/svg+xml 及任何 image/svg* 变体直接 400(SVG 能嵌 <script>,走 R2 公开 URL 会触发存储型 XSS)
  • fileSize 改必填:之前是 optional,缺省时 ContentLength 不进预签名 URL → R2 不强制大小 → 10 MB 上限形同虚设。现在缺少/非法一律 400/413;ContentLength 恒绑
  • primary MIME 提取split(";")[0].trim().toLowerCase() 切掉参数再校验 + 送 R2,阻断 "image/jpeg; image/svg+xml" 这类 polyglot
  • MIME 格式正则^[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/
  • settingsrobots.follow: false(原来是 true)
  • normalizeSiteUrl + SITE_URLlib/site-url.tsrobots.tssitemap.ts 共用

类型清洁 (lib/search-index.ts)

  • 干掉 as any cast
  • 改用 fumadocs-core/mdx-plugins 公开导出的 StructuredData,删掉本地副本(避免上游升级静默漂移)

Known follow-ups

  • R2 serve 层 X-Content-Type-Options: nosniff header — 属 Caddy / R2 bucket 配置,另开 PR
  • app/api/upload/route.ts 当前 0 测试覆盖;下个 PR 补一套 vitest 覆盖完整校验链(fileSize 缺失/非法/超大、non-image、SVG、polyglot、CRLF 注入)

Test plan

  • pnpm tsc --noEmit — clean at each commit
  • Pre-commit hooks(prettier / vitest / image lint / pnpm-lockfile)— pass
  • 两路对抗 reviewer(security red-team + architecture critic)三轮 CR,最终均 APPROVE
  • 手工在 staging 验证:正常图片上传正常、SVG / polyglot / CRLF 全部 400

🤖 Generated with Claude Code

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/冒号/空格注入一律挡死。
Copilot AI review requested due to automatic review settings April 24, 2026 20:42
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
involutionhell-github-io Ready Ready Preview, Comment Apr 25, 2026 7:03am
website-preview Ready Ready Preview, Comment Apr 25, 2026 7:03am

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 binds ContentLength/ContentType into the R2 presigned PUT.
  • Adds app/robots.ts, tightens /settings page robots metadata, and centralizes SITE_URL normalization in lib/site-url.ts for sitemap/robots reuse.
  • Removes as any from lib/search-index.ts by using StructuredData from fumadocs-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.

Comment thread lib/site-url.ts Outdated
Comment on lines +17 to +22
* 原始 env 值,fallback 到生产域名。
* 不直接 export —— 消费方只应该拿规范化后的 SITE_URL。
*/
const RAW_SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? "https://involutionhell.com";

Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RAW_SITE_URL 在 env 缺失时 fallback 到生产域名会让“漏配/误配”变成静默产出指向生产的 sitemap/robots URL(尤其在 preview/staging 环境下风险更高)。仓库文档里已明确“生产环境不做硬编码 fallback…新代码应该在 env 缺失时显式报错或返回空结果”(docs/architecture/frontend-backend-separation.md:96-103)。建议至少在非 dev 环境下缺失时直接 throw/返回空值并记录错误,避免生成错误域名的 SEO 产物。

Suggested change
* 原始 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();

Copilot uses AI. Check for mistakes.
Comment on lines 93 to +97
body: JSON.stringify({
filename: file.name,
contentType: file.type,
articleSlug,
fileSize: file.size,
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

客户端在 PUT 到 R2 时仍使用 file.type 作为 Content-Type,但服务端签名时已将 ContentType 规范化为 primaryMime(小写、去参数)。如果浏览器/环境给出的 file.type 带参数或大小写不同,会导致预签名 URL 因签名头不匹配而上传失败。建议在客户端对 file.type 做同样的 primary MIME 归一化,并在 POST /api/upload 的 contentType 和后续 PUT 的 Content-Type 里使用同一个规范化值(或由服务端返回规范化后的值供客户端使用)。

Copilot uses AI. Check for mistakes.
Comment thread app/api/upload/route.ts Outdated
Comment on lines +130 to +131
if (!Number.isFinite(fileSize) || fileSize < 0) {
return NextResponse.json({ error: "fileSize 参数无效" }, { status: 400 });
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fileSize 目前只校验了 finite 且 >= 0,但 Content-Length/ContentLength 语义上应为非负整数。恶意/异常客户端传入小数会导致签名绑定的 content-length 不是合法值,进而造成上传无法成功或产生不可预期行为。建议改为校验 Number.isSafeInteger(fileSize),并视需求拒绝 0 字节文件。

Suggested change
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 },
);

Copilot uses AI. Check for mistakes.
之前 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 一次过。
@longsizhuo longsizhuo merged commit 905a30a into main Apr 25, 2026
6 of 8 checks passed
@longsizhuo longsizhuo deleted the fix/security-hardening-misc branch April 25, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants