From a96685060f3143d01574cfe8aaeafda3f5620c16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:30:35 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(proxy):=20=E9=9D=9E=20ASCII=20docs=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=20edge=20=E7=9B=B4=E6=8E=A5=20404=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20x-next-cache-tags=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 爬虫构造的不存在中文 docs 路径(如 ".../你的图片.jpg \"悬停名\"")会进 [...slug] 的运行时动态渲染,Vercel 把中文写进 x-next-cache-tags 响应头, Node 拒绝 >0xFF 的 header 字符 → 未捕获 TypeError → 500。上游 vercel/next.js#92145 未修,页面内拦不住(崩溃在 notFound() 之后的框架层)。 - matcher 的 dot-path 排除新增 docs 例外(对齐 leetcode 例外),让带点的 垃圾路径能进 middleware;/docs/ URL 下无真实静态资源,放开安全 - 新增 isPoisonedDocsPath:解码后仍含 >0xFF 字符的 docs 路径返回裸 404。 合法 docs slug 全 ASCII(中文 leetcode 旧 URL 由 slug-map 先行 301, doc_paths 历史表无非 leetcode 中文行),不误伤 resolve 重定向 - ASCII 未知路径仍走 page.tsx 的 resolve→301/404 流程,行为不变 Fixes INVOLUTIONHELL-FRONTEND-4 Closes #370 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RtSNVtrEkCsFVHrbt4Zjgi --- lib/poisoned-docs-path.ts | 22 ++++++++++++ proxy.ts | 41 ++++++++++++++++------- tests/proxy-guard.test.ts | 70 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 lib/poisoned-docs-path.ts create mode 100644 tests/proxy-guard.test.ts diff --git a/lib/poisoned-docs-path.ts b/lib/poisoned-docs-path.ts new file mode 100644 index 00000000..b8e3ab06 --- /dev/null +++ b/lib/poisoned-docs-path.ts @@ -0,0 +1,22 @@ +/** + * 非 ASCII 的未知 docs 路径在 Vercel 运行时会把中文写进 x-next-cache-tags + * 响应头导致 500(vercel/next.js#92145,上游未修,见 #370)。合法 docs slug + * 全是 ASCII——中文 leetcode 旧 URL 由 proxy.ts 的 slug-map 先行 301 成 + * 拼音页,doc_paths 历史表里也没有非 leetcode 的中文行——所以解码后仍含 + * >0xFF 字符(Node header 的 latin1 上限)的 docs 路径必是爬虫垃圾, + * edge 直接 404。 + * + * 独立成模块是为了 vitest 可测:proxy.ts 引 next-intl/middleware, + * node 环境下 import 不动。 + */ +export function isPoisonedDocsPath(pathname: string): boolean { + const stripped = pathname.replace(/^\/(zh|en)(?=\/)/, ""); + if (!stripped.startsWith("/docs/")) return false; + let decoded = stripped; + try { + decoded = decodeURIComponent(stripped); // 防二次编码,对齐 redirectLeetcodeIfNeeded + } catch { + return true; // 畸形编码同样进不了 header,一并 404 + } + return /[^ -ÿ]/.test(decoded); +} diff --git a/proxy.ts b/proxy.ts index ec62c85f..16d73b51 100644 --- a/proxy.ts +++ b/proxy.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import createMiddleware from "next-intl/middleware"; import leetcodeSlugMap from "@/generated/leetcode-slug-map.json"; import { routing } from "@/i18n/routing"; +import { isPoisonedDocsPath } from "@/lib/poisoned-docs-path"; /** * Edge proxy(Next.js 16 旧称 middleware)。 @@ -121,20 +122,31 @@ function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null { return NextResponse.redirect(url, 301); } +// 裸 404,no-store 避免攻击者拿 200 当命中信号、也避免 CDN 缓存垃圾响应。 +function bare404(): NextResponse { + return new NextResponse(null, { + status: 404, + headers: { "cache-control": "no-store" }, + }); +} + export function proxy(req: NextRequest) { - // Scanner 路径 0 函数调用直接 404,no-store 避免攻击者拿 200 当命中信号。 + // Scanner 路径 0 函数调用直接 404。 if (isBotScanPath(req.nextUrl.pathname)) { - return new NextResponse(null, { - status: 404, - headers: { "cache-control": "no-store" }, - }); + return bare404(); } - // 1. Leetcode 中文 slug 优先做 301 + // 1. Leetcode 中文 slug 优先做 301(必须在 poisoned 检查之前, + // 否则合法的中文旧 URL 会被 404 而不是重定向到拼音页) const leetcodeRedirect = redirectLeetcodeIfNeeded(req); if (leetcodeRedirect) return leetcodeRedirect; - // 2. 其它请求交给 next-intl 处理 locale routing + // 2. 未命中 slug-map 的非 ASCII docs 路径:edge 404,不进 Fluid + if (isPoisonedDocsPath(req.nextUrl.pathname)) { + return bare404(); + } + + // 3. 其它请求交给 next-intl 处理 locale routing return intlMiddleware(req); } @@ -146,10 +158,15 @@ export const config = { // rewrite source(/oauth/:path*)不匹配带 locale 的版本(/en/oauth/...), // 落到 [locale]/oauth/... 404。所以必须排除掉,让请求直接走 rewrite。 // - _next / _vercel:Next.js 内部 - // - (?!.*[Ll]eetcode).*\..*:带 . 的路径(静态资源 / sitemap.xml 等)一律排除, - // 但 leetcode 例外——GSC 旧 URL 里有大量带点的中文题名("46.全排列"、 - // "1234. 替换…"),不放进来就触不到上面的 slug-map 301,只能硬 404。 - // leetcode 目录下不存在带点的静态资源,开这个口子安全。 + // - (?!.*[Ll]eetcode|(?:zh/|en/)?docs/).*\..*:带 . 的路径(静态资源 / + // sitemap.xml 等)一律排除,两个例外: + // 1. leetcode——GSC 旧 URL 里有大量带点的中文题名("46.全排列"、 + // "1234. 替换…"),不放进来就触不到上面的 slug-map 301,只能硬 404。 + // leetcode 目录下不存在带点的静态资源,开这个口子安全。 + // 2. docs——爬虫会构造带点又带中文的垃圾 docs 路径(如 ".../你的图片.jpg + // \"悬停名\""),必须进 middleware 让 isPoisonedDocsPath 拦下,否则 + // 直达 [...slug] lambda 触发 x-next-cache-tags 500(#370)。 + // /docs/ URL 下没有真实静态资源(public/ 无 docs 子目录),同样安全。 matcher: - "/((?!api|trpc|auth|oauth|analytics|_next|_vercel|(?!.*[Ll]eetcode).*\\..*).*)", + "/((?!api|trpc|auth|oauth|analytics|_next|_vercel|(?!.*[Ll]eetcode|(?:zh/|en/)?docs/).*\\..*).*)", }; diff --git a/tests/proxy-guard.test.ts b/tests/proxy-guard.test.ts new file mode 100644 index 00000000..fb0b8db9 --- /dev/null +++ b/tests/proxy-guard.test.ts @@ -0,0 +1,70 @@ +/** + * #370:非 ASCII 的未知 docs 路径必须在 edge 被 404 短路,否则会进 + * [...slug] lambda,Vercel 运行时把中文写进 x-next-cache-tags 响应头 + * 触发 500(vercel/next.js#92145,上游未修)。 + * + * 同时守住 matcher 的两个不变量: + * - docs 带点路径必须能进 middleware(isPoisonedDocsPath 生效的前提, + * 崩溃 URL 里恰好带 .jpg) + * - 非 docs 的 dot-path(静态资源)和 backend rewrite 路径仍被排除 + */ +import { describe, expect, test } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isPoisonedDocsPath } from "@/lib/poisoned-docs-path"; + +const ROOT = join(__dirname, ".."); + +/** + * 从 proxy.ts 源码提取 matcher 字符串并按 Next 的锚定语义编译。 + * 源码文本里的 `\\` 在 JS 字符串字面量里是 `\`,先还原再建 RegExp。 + */ +function matcherRegex(): RegExp { + const content = readFileSync(join(ROOT, "proxy.ts"), "utf-8"); + const m = content.match(/matcher:\s*["'`]([^"'`]+)["'`]/); + if (!m) throw new Error("无法定位 proxy.ts 的 matcher 字段"); + return new RegExp(`^${m[1].replace(/\\\\/g, "\\")}$`); +} + +describe("isPoisonedDocsPath", () => { + test.each<[string, boolean]>([ + // Sentry INVOLUTIONHELL-FRONTEND-4 的原始崩溃路径 + ['/zh/docs/learn/cs/dev-tips/你的图片.jpg "自定义鼠标悬停显示名"', true], + // 畸形 percent-encoding:decodeURIComponent 会 throw,同样 404 + ["/docs/%e4%b8", true], + // ASCII 垃圾路径归 page.tsx 的 resolve→404 流程管,不在这里拦 + ["/docs/learn/ai/multimodal/[object Object]", false], + // 正常 docs 页 + ["/zh/docs/learn/ai/multimodal-overview", false], + ["/en/docs/career/interview-prep/leetcode/46-quan-pai-lie", false], + // 不越界到 docs 以外的路由 + ["/zh/u/中文名", false], + ["/", false], + ])("%s → %s", (path, expected) => { + expect(isPoisonedDocsPath(path)).toBe(expected); + }); +}); + +describe("matcher:docs 带点路径进 middleware,其余排除规则不回归", () => { + const re = matcherRegex(); + + test.each([ + "/zh/docs/foo.jpg", + "/docs/a/b.png", + '/zh/docs/learn/cs/dev-tips/你的图片.jpg "自定义鼠标悬停显示名"', + // leetcode 带点中文旧 URL:一直要能进 middleware 吃 slug-map 301 + "/docs/CommunityShare/Leetcode/46.全排列", + ])("进 middleware: %s", (p) => { + expect(re.test(p)).toBe(true); + }); + + test.each([ + "/mascot.png", + "/sitemap.xml", + // backend rewrite 路径(参考 PR #335 登录事故,另见 proxy-matcher.test.ts) + "/oauth/render/github", + "/api/admin/events", + ])("仍被排除: %s", (p) => { + expect(re.test(p)).toBe(false); + }); +}); From 06d460d31557914370f0a8a388dd57edbc293fb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:28:20 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(proxy):=20=E5=8E=BB=E6=8E=89=20poisoned?= =?UTF-8?q?-docs-path=20=E9=87=8C=E7=9A=84=E6=AD=BB=E8=B5=8B=E5=80=BC?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-code-quality[bot] Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RtSNVtrEkCsFVHrbt4Zjgi --- lib/poisoned-docs-path.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/poisoned-docs-path.ts b/lib/poisoned-docs-path.ts index b8e3ab06..3551792b 100644 --- a/lib/poisoned-docs-path.ts +++ b/lib/poisoned-docs-path.ts @@ -12,7 +12,7 @@ export function isPoisonedDocsPath(pathname: string): boolean { const stripped = pathname.replace(/^\/(zh|en)(?=\/)/, ""); if (!stripped.startsWith("/docs/")) return false; - let decoded = stripped; + let decoded: string; try { decoded = decodeURIComponent(stripped); // 防二次编码,对齐 redirectLeetcodeIfNeeded } catch { From 93c43982f9e4a3298aa3e090b79c6b1878d059cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:29:25 +0000 Subject: [PATCH 3/3] =?UTF-8?q?style(proxy):=20=E6=B3=A8=E9=87=8A=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=20issue=20=E7=BC=96=E5=8F=B7=E5=BC=95=E7=94=A8?= =?UTF-8?q?=EF=BC=88=E9=81=B5=E5=BE=AA=20CLAUDE.md=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E8=A7=84=E5=88=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RtSNVtrEkCsFVHrbt4Zjgi --- lib/poisoned-docs-path.ts | 2 +- proxy.ts | 2 +- tests/proxy-guard.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/poisoned-docs-path.ts b/lib/poisoned-docs-path.ts index 3551792b..1ee9aad9 100644 --- a/lib/poisoned-docs-path.ts +++ b/lib/poisoned-docs-path.ts @@ -1,6 +1,6 @@ /** * 非 ASCII 的未知 docs 路径在 Vercel 运行时会把中文写进 x-next-cache-tags - * 响应头导致 500(vercel/next.js#92145,上游未修,见 #370)。合法 docs slug + * 响应头导致 500(vercel/next.js#92145,上游未修)。合法 docs slug * 全是 ASCII——中文 leetcode 旧 URL 由 proxy.ts 的 slug-map 先行 301 成 * 拼音页,doc_paths 历史表里也没有非 leetcode 的中文行——所以解码后仍含 * >0xFF 字符(Node header 的 latin1 上限)的 docs 路径必是爬虫垃圾, diff --git a/proxy.ts b/proxy.ts index 16d73b51..c3f852c3 100644 --- a/proxy.ts +++ b/proxy.ts @@ -165,7 +165,7 @@ export const config = { // leetcode 目录下不存在带点的静态资源,开这个口子安全。 // 2. docs——爬虫会构造带点又带中文的垃圾 docs 路径(如 ".../你的图片.jpg // \"悬停名\""),必须进 middleware 让 isPoisonedDocsPath 拦下,否则 - // 直达 [...slug] lambda 触发 x-next-cache-tags 500(#370)。 + // 直达 [...slug] lambda 触发 x-next-cache-tags 500。 // /docs/ URL 下没有真实静态资源(public/ 无 docs 子目录),同样安全。 matcher: "/((?!api|trpc|auth|oauth|analytics|_next|_vercel|(?!.*[Ll]eetcode|(?:zh/|en/)?docs/).*\\..*).*)", diff --git a/tests/proxy-guard.test.ts b/tests/proxy-guard.test.ts index fb0b8db9..23d2b248 100644 --- a/tests/proxy-guard.test.ts +++ b/tests/proxy-guard.test.ts @@ -1,5 +1,5 @@ /** - * #370:非 ASCII 的未知 docs 路径必须在 edge 被 404 短路,否则会进 + * 非 ASCII 的未知 docs 路径必须在 edge 被 404 短路,否则会进 * [...slug] lambda,Vercel 运行时把中文写进 x-next-cache-tags 响应头 * 触发 500(vercel/next.js#92145,上游未修)。 *