Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lib/poisoned-docs-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* 非 ASCII 的未知 docs 路径在 Vercel 运行时会把中文写进 x-next-cache-tags
* 响应头导致 500(vercel/next.js#92145,上游未修)。合法 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: string;
try {
decoded = decodeURIComponent(stripped); // 防二次编码,对齐 redirectLeetcodeIfNeeded
} catch {
return true; // 畸形编码同样进不了 header,一并 404
}
return /[^ -ÿ]/.test(decoded);
}
41 changes: 29 additions & 12 deletions proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down Expand Up @@ -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);
}

Expand All @@ -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。
// /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/).*\\..*).*)",
};
70 changes: 70 additions & 0 deletions tests/proxy-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* 非 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);
});
});
Loading