Skip to content

Commit 8cdd696

Browse files
os-zhuanghotlongclaude
authored
fix(docs): reject non-locale [lang] segments instead of serving the homepage (#12258)
`apps/docs/app/[lang]/` matches ANY single path segment, and `proxy.ts`'s matcher deliberately excludes dotted paths from locale rewriting (static assets must not be rewritten). So every dotted single-segment URL skipped the proxy, landed on `[lang]` with `lang` set to that literal segment, and rendered the full homepage under a 200 -- an unbounded set of duplicate homepages at exactly the URLs crawlers probe by default, including `/robots.txt` and `/sitemap.xml`. Measured before the fix on the dev server: `/foo.txt`, `/ads.txt`, `/security.txt`, `/anything.html`, `/sitemap_index.xml`, `/robots.txt` and `/sitemap.xml` all returned 200 with the homepage, while `/this-page-does-not-exist` correctly returned 404. A temporary probe in the layout printed `lang="foo.txt"`, `lang="ads.txt"`, `lang="robots.txt"`, `lang="sitemap.xml"` against `lang="en"` for `/docs`, `/blog` and `/`. The declared locales are the contract, so enforce them where they are violated: `lib/i18n.ts` gains `isSupportedLanguage()` derived from `i18n.languages`, and the `[lang]` layout calls `notFound()` before it renders anything when the segment is not a declared locale. `scripts/check-docs-locale-catch-all.mjs` pins it. The guard is three lines in a layout that is otherwise pure presentation, and deleting it breaks nothing any other check can see -- every page still renders, every type still checks, every link still resolves; the only symptom is a 200 where a 404 belongs, on URLs no test requests. The gate checks the two halves as one conditional invariant (if dotted paths bypass the proxy, then every top-level dynamic segment must reject a non-locale parameter before rendering), so it reasons rather than pattern-matches and covers the class rather than the file. Co-authored-by: Jack Zhuang <zhuangjianguo@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent daae7aa commit 8cdd696

5 files changed

Lines changed: 465 additions & 1 deletion

File tree

.github/workflows/lint.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,27 @@ jobs:
11111111
- name: Docs redirect destinations resolve, and no chains
11121112
run: pnpm check:docs-redirects
11131113

1114+
# The docs site's `app/[lang]/` is a catch-all that matches ANY single
1115+
# path segment, and `proxy.ts`'s matcher deliberately excludes dotted
1116+
# paths from locale rewriting — so before the guard landed, `/ads.txt`,
1117+
# `/security.txt`, `/sitemap_index.xml` and every other dotted
1118+
# single-segment URL rendered the full homepage under a 200 (measured on
1119+
# the dev server; the request log shows no `proxy.ts:` timing for those
1120+
# paths, and a probe printed `lang="ads.txt"`).
1121+
#
1122+
# It sits here rather than in a test because nothing else can see the
1123+
# regression: delete the three-line guard and every page still renders,
1124+
# every type still checks, every link still resolves. The only symptom is
1125+
# a 200 where a 404 belongs, on URLs no test requests.
1126+
#
1127+
# Runs its own --self-test first (via the pnpm script). Not ceremony: the
1128+
# live tree is green by construction after the fix, so a passing run over
1129+
# real data cannot distinguish a working gate from one that approves
1130+
# everything. The self-test is where every limb is observed failing, and
1131+
# where the proxy condition is observed flipping the requirement off.
1132+
- name: Docs locale catch-all rejects non-locale segments
1133+
run: pnpm check:docs-locale-catch-all
1134+
11141135
# #11050 route spellings taught in prose: every /api/v1 wire-path
11151136
# literal in the published corpora (content/docs/** minus releases/,
11161137
# plus skills/**) is judged against the route ledgers, and a literal

apps/docs/app/[lang]/layout.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ReactNode } from 'react';
22
import { RootProvider } from 'fumadocs-ui/provider/next';
3-
import { i18n } from '@/lib/i18n';
3+
import { notFound } from 'next/navigation';
4+
import { i18n, isSupportedLanguage } from '@/lib/i18n';
45

56
// Language display names mapping
67
const LANGUAGE_NAMES: Record<string, string> = {
@@ -16,6 +17,16 @@ export default async function LanguageLayout({
1617
children: ReactNode;
1718
}) {
1819
const { lang } = await params;
20+
21+
// `[lang]` is a catch-all: it matches ANY single path segment. Paths that
22+
// contain a dot reach it unrewritten, because `proxy.ts`'s matcher excludes
23+
// them from locale rewriting on purpose (static assets must not be
24+
// rewritten) -- so `/ads.txt`, `/anything.html` and `/sitemap_index.xml`
25+
// arrive here with `lang` set to that literal segment. Without this check
26+
// every one of them renders the homepage under a 200, publishing an
27+
// unbounded set of duplicate homepages at exactly the URLs crawlers probe.
28+
// Declared locales are the contract; reject anything else.
29+
if (!isSupportedLanguage(lang)) notFound();
1930

2031
return (
2132
<RootProvider

apps/docs/lib/i18n.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,18 @@ export const i18n = defineI18n({
1515
// Hide locale prefix for default language (e.g., /docs instead of /en/docs)
1616
hideLocale: 'default-locale',
1717
});
18+
19+
/**
20+
* True when `value` is one of the locales declared above.
21+
*
22+
* The `[lang]` route segment is a catch-all: without this check it matches ANY
23+
* single path segment and renders the homepage under it. Paths containing a dot
24+
* are the reachable case, because `proxy.ts`'s matcher deliberately excludes
25+
* them from locale rewriting (static assets must not be rewritten), so they
26+
* arrive at `[lang]` with `lang` set to the literal segment — `"robots.txt"`,
27+
* `"ads.txt"`, `"anything.html"`. Declared locales are the contract; this is
28+
* where it is enforced.
29+
*/
30+
export function isSupportedLanguage(value: string): boolean {
31+
return (i18n.languages as readonly string[]).includes(value);
32+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs",
4343
"check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs",
4444
"check:docs-redirects": "node scripts/check-docs-redirects.mjs --self-test && node scripts/check-docs-redirects.mjs",
45+
"check:docs-locale-catch-all": "node scripts/check-docs-locale-catch-all.mjs --self-test && node scripts/check-docs-locale-catch-all.mjs",
4546
"check:docs-image-tag": "node scripts/check-docs-image-tag.mjs --self-test && node scripts/check-docs-image-tag.mjs",
4647
"check:docs-image-tag-sync": "node scripts/sync-docs-image-tags.mjs --self-test",
4748
"check:react-page-adapter-contract": "node scripts/check-react-page-adapter-contract.mjs --self-test && node scripts/check-react-page-adapter-contract.mjs",

0 commit comments

Comments
 (0)