Skip to content

A URL whose first segment contains a dot bypasses the locale middleware and returns HTTP 500, not 404 #180

Description

@hotlong

Found while verifying the locale work on #168. Pre-existing on main at ee74379; unrelated to that branch and deliberately not fixed there.

(Angle-bracket markup omitted throughout — this repo's sanitizer strips it.)

What happens

apps/docs/middleware.ts:187 matches every path except /((?!api|_next/static|_next/image|favicon.ico|.*\..*).*). The final .*\..* alternative means any path containing a dot skips the middleware entirely — the rule is there to let static assets through, but it is not anchored to a file extension or to the last segment.

So a request whose first segment contains a dot never gets a locale normalized onto it, and lands directly on the app/[lang]/... routes with lang set to something that is not a locale.

For the docs route that is not a 404 — it is an unhandled exception:

GET /foo.bar/docs   ->  HTTP 500

Server log:

TypeError: Cannot use 'in' operator to search for 'root' in undefined
    at f (.next/server/chunks/ssr/[root-of-the-server]__0hg5nia._.js:3:7477)
  digest: '522393500'

Thrown out of the Fumadocs page-tree lookup, which is handed a language it has no tree for.

Reproduction

cd apps/docs && pnpm run build && npx next start -p 3187
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3187/foo.bar/docs   # 500
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3187/foo.bar/privacy # 200

Confirmed on a clean origin/main worktree at ee74379, so it predates any of the current locale branches.

Why it is worth fixing

  • A 500 is a server-error signal to crawlers and to any uptime monitor, where the honest answer is 404. Arbitrary dotted paths are exactly what vulnerability scanners and stale inbound links produce, so this is reachable without anyone doing anything unusual.
  • The sibling routes answer 200 rather than 404: /foo.bar/privacy and /foo.bar/terms both render a full page under a bogus locale. Those are unlocalized pages, so they render, but they are being served at a URL that should not resolve at all.
  • It is a real, if narrow, hole in the "every path gets a normalized locale" invariant that the middleware otherwise maintains.

Possible directions (not adjudicated)

  1. Tighten the middleware matcher so the dot exemption only applies to a genuine asset path — anchored to the last segment, or to a known extension list — instead of any path containing a dot anywhere.
  2. Have the locale segment reject non-locale values outright, so an unknown first segment 404s at the route level regardless of how it arrived.

Direction 1 addresses the cause; direction 2 is a backstop for anything that reaches the routes with a bad segment by another path. They are not mutually exclusive.

Filed unassigned for triage.


Generated by Claude Code

Activity

  1. added theissue type on Aug 25, 2026
  2. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    Graded: pm:queue confirmed / Bug. Direction adjudicated below — the card left two open and they are not equally safe.

    Claim: PM loop round 10
    Session: session_01G5zjYc2BoFV2NjKBBapC7C
    Branch: claude/issue-180-dotted-segment-500
    Worktree: objectos-issue-180
    Domain: n/a (objectos carries no domain:* lanes)
    File surface: apps/docs/app/[lang]/layout.tsx. Stop on breach; explain in the report. middleware.ts is read-only for you — see the adjudication.
    Container and model: M, mode:subagent, model: opus — a live-server verification leg and a hypothesis that must be measured before it is implemented.
    Clause-②: no
    Serial constraints: middleware.ts was rewritten by #204 (3433803, merged minutes ago) — rebase on current main and read the tree. #191 is queued behind you in this same route path and will not start until you land.

    Adjudication: take direction 2. Direction 1 is out of scope for this card.

    The card offers tightening the matcher (direction 1) or rejecting non-locale locale segments at the route level (direction 2), and calls them not mutually exclusive. They are not equally cheap, and direction 1 is more dangerous than it reads.

    That matcher's dot exemption is currently load-bearing for routes, not just assets. /llms.txt, /llms-full.txt, /sitemap.xml, /robots.txt and every .mdx page route (page.url plus .mdx, which the copy button links to) all contain dots and are all exempted by it today. Tighten it carelessly and those start going through locale negotiation and acquire locale prefixes. The locale-surface gate would not catch that: it reads build output, not a running server. So direction 1 is a change whose blast radius is the site's entire machine-facing surface, guarded by nothing, in service of a narrower fix than direction 2 gives.

    Direction 2 also fixes strictly more. The card notes /foo.bar/privacy answers 200 — that is a soft 404, and an unbounded family of them: any dotted first segment yields a full page at a URL that should not resolve. For a docs site that is a crawl-budget and duplicate-URL hole, and it is the more SEO-relevant half of this bug than the 500 is.

    So: make a non-locale first segment 404 at the route level. Both the 500 on /foo.bar/docs and the 200 on /foo.bar/privacy become 404. If you come out of this convinced the matcher is also wrong, file it with the measurement; do not fix it here.

    The mechanism is a hypothesis. Measure it before you implement it.

    The shape I would try first — and it is a guess, not an instruction: dynamicParams = false on the [lang] segment, mirroring exactly what #182 did for unknown docs slugs. generateStaticParams there already returns the seven languages, so an unknown lang would resolve to the prerendered /_not-found route.

    Do not reach for notFound() in the layout without measuring it. #182 established that notFound() in a dynamically rendered request escapes both the RSC and SSR renders and yields an empty error shell — and /foo.bar/docs is dynamically rendered by construction, since it is not in any prerendered param set. That is the exact condition #182 documented. A notFound() fix here could turn a 500 into a 404 status with a blank body and look like a success.

    #182 is the cautionary case in both directions: its original fix was reasonable, widely recommended, and a measured no-op, and that surfaced only because the dev built it and diffed the output.

    Verification

    • Against a running build (next start), not read off the source: /foo.bar/docs, /foo.bar/privacy, /foo.bar/terms all 404.
    • Use the script-stripped curl from A 404 under /docs serves a blank error shell, not the 404 page — notFound() from a docs page never renders server-side #182. A raw grep cannot distinguish server-rendered markup from the RSC payload and will report success on a page with no visible body. Report the status code and evidence the 404 markup is actually in the server-rendered HTML.
    • Controls that must not regress, all against the running server: /docs/architecture 200, /zh-Hans/docs/architecture 200, /privacy 200, /cn/docs/architecture still 308 to /zh-Hans/..., and the www.objectos.app domain redirect still 308 to docs.objectos.ai (fix(docs): collapse the metadataBase hostname into SITE_URL, record the middleware decision #204 left a six-case table; reuse it).
    • /llms.txt, /llms-full.txt, /sitemap.xml, /robots.txt, and one .mdx route still 200 and still served without a locale prefix — this is the direction-1 hazard, and I want it confirmed untouched even though you are not touching the matcher.
    • node .github/scripts/check-locale-surface.mjs exit 0.

    Note the turbo cache is shared across worktrees and replays sibling greens; use --force where it matters and read each gate's own verdict line.


    Generated by Claude Code

  3. self-assigned this
    on Aug 26, 2026
  4. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 180,
      "status": "done",
      "branch": "claude/issue-180-dotted-segment-500",
      "pr": "https://github.com/objectstack-ai/objectos/pull/209",
      "premise_still_valid": false,
      "summary": "Half the card's premise is dead and half is live, so the PR covers the live half. Measured on unmodified main at 3433803 BEFORE editing anything: /foo.bar/docs already returns 404 with real 404 copy in the server markup — #192 (6653744) set dynamicParams=false on the docs page hours after this card was filed and incidentally routes an unknown lang to /_not-found before the Fumadocs tree lookup can throw. There is no reproducible 500 left. Still live on main, and fixed here: /foo.bar/privacy 200, /foo.bar/terms 200, plus two the card did not count — /foo.bar 307 to /foo.bar/docs, and /favicon.ico 307 to /favicon.ico/docs. Shipped direction 2 as adjudicated: one line, export const dynamicParams = false on apps/docs/app/[lang]/layout.tsx, whose generateStaticParams already enumerated the seven locales without anything consuming that as a constraint. middleware.ts was read and not touched.",
      "tests": "All readings against `next start` on a production build, never off the source; script bodies stripped with the perl one-liner from #192 before any grep. FIX (b61f04e): /foo.bar/docs, /foo.bar/privacy, /foo.bar/terms, /foo.bar, /1.2.3/privacy, /a.b/docs/architecture and /favicon.ico all 404, and the served document is BYTE-IDENTICAL to /no-such-page — sha256 of the stripped body is fa59d702c28d07c8 for all seven and for /no-such-page; controls /privacy e3969814557ea22e and /docs/architecture accd8e8122687cf5d differ, so the hash discriminates. MECHANISM MEASURED, NOT ASSUMED (the PM's warning reproduced): built a notFound()-guard variant of the same layout — /foo.bar/privacy answered 404 with a 594-byte empty error shell and ZERO 404 copy, i.e. a status-only check would have scored it a fix; dynamicParams=false gives 1518 bytes with the copy. That probe declared its restore in a trap over absolute paths, proved the mutation on disk by grep counts on the injected and removed text (injected=1, flag removed=0) before building, and proved its restore by blob identity against HEAD (2175018dfdf9326b926b0a6b8e282c6d24cb034c), not by an exit code. NAIVE-GREP TRAP RECONFIRMED on this tree: raw grep scores the 404 copy present on healthy 200 pages /docs/architecture and /privacy; stripped scores them 0. CONTROLS, every row identical before and after: /docs/architecture 200 lang=en, /zh-Hans/docs/architecture 200 lang=zh-Hans, /ja/docs/architecture 200 lang=ja, /privacy + /terms + /zh-Hans/privacy 200, /docs 200, / 307 to /docs, /no-such-page + /docs/no-such-page + /zh-Hans/docs/no-such-page 404 with body (#168 and #192 preserved), /cn/docs/architecture 308 to /zh-Hans/docs/architecture, /cn/privacy 308 to /zh-Hans/privacy, Accept-Language zh-CN 307 to /zh-Hans/... and ja 307 to /ja/.... SIX-CASE HOST TABLE from #204 re-run, all six as recorded, incl. Host: www.objectos.app 308 to https://docs.objectos.ai/... with query and locale prefix preserved, and Host: docs.objectos.ai 200. DIRECTION-1 HAZARD confirmed untouched: /llms.txt 200 14557B, /llms-full.txt 200 685243B, /sitemap.xml 200 299145B, /robots.txt 200 117B, /docs/architecture.mdx 200 12488B — all with no redirect and no locale prefix. ENUMERATION INTACT: 996/996 prerendered both sides, 577 prerendered HTML files (matches #192/#204). GATES at b61f04e, exit captured before any pipe, --force on all three turbo tasks because the cache is shared across worktrees: `pnpm turbo run type-check --continue --force` -> 'Tasks: 1 successful, 1 total' with the script echoed (fumadocs-mdx && next typegen && tsc --noEmit); `pnpm turbo run build --force` -> 'Compiled successfully in 39.3s'; `pnpm turbo run test --force` -> 'Tasks: 1 successful, 1 total', '16 case(s) over 9 rule(s) and 3 artifact(s)', '3 self-test(s) passed'; `node .github/scripts/check-locale-surface.mjs` -> exit 0, 346/346 sitemap URLs 0 unexpected 0 missing, both llms bodies 60/60; `node .github/scripts/check-node-floor.mjs` -> exit 0. CLEANUP: all five next servers I started were found by walking /proc for cwd inside my own worktree and killed by explicit PID (never pkill -f); all five ports then refuse connections (curl exit 7). Worktree removed without --force.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #207: the .mdx copy/view links 404 on all six non-default locales — next.config.mjs rewrites only the unprefixed /docs/:path*.mdx, so every /{locale}/docs/{slug}.mdx that the page renders as markdownUrl falls through to the docs route and 404s (~400 dead links across 79 pages x 6 locales); measured by reading markdownUrl out of the served payload and probing each",
        "filed as #208 (finding label): a docs page whose SLUG contains a dot would 404 at its public URL and answer 200 at the internal /en/ route, because the matcher skips it and the default-locale rewrite never runs; latent — zero such pages exist today — but measured with a temporary probe page (content/docs/probe.dotted.mdx, built, served, removed, restore proven by git status clean) rather than reasoned",
        "not filed, reported instead: /favicon.ico changes from 307-into-a-404 to a clean 404. apps/docs/public/ holds only logo.svg and the root metadata declares icon: '/logo.svg', so no favicon.ico exists either way — strictly an improvement, but a behaviour change the card did not predict, so it is called out in the PR body",
        "on the matcher (the PM invited this): after this fix I could produce NO live consequence of the unanchored .*\\..* exemption. #208 is the only one left and it has zero instances. Recorded with its measurement, not fixed here — the adjudication that direction 1's blast radius is not worth this card's risk holds"
      ]
    }

    Generated by Claude Code

  5. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — merging #209.

    CI 2/2 green on b61f04e, base c5e7905 (current main), one file, exactly the granted surface, middleware.ts read and not touched.

    The card was half stale, and that surfaced because main was measured before anything was written

    I filed this against ee74379 claiming /foo.bar/docs returns 500. On current main it returns 404 with real 404 copy — the dynamicParams = false that landed on the docs route (6653744) fixed it incidentally, hours later, for an unrelated reason. There was no reproducible 500 left.

    That is the third time today a dev has opened a card by measuring its premise and found it had moved. It keeps being worth the two minutes: the alternative here was a dev hunting a 500 that no longer exists.

    And the half that was live turned out larger than I described. Two rows I never counted:

    request on main before this fix
    /foo.bar 307 to /foo.bar/docs
    /favicon.ico 307 to /favicon.ico/docs

    So the soft-404 family — the half I adjudicated as the more SEO-relevant one — was undercounted in my own card.

    The trap I warned about was demonstrated, not taken on faith

    I flagged notFound() in the layout as the shape that looks right and fails silently. The dev built it and produced the number:

    /foo.bar/privacy status stripped body bytes 404 copy present
    main (no fix) 200 9,325 no — a whole privacy page
    notFound() guard 404 594 no — empty error shell
    dynamicParams = false 404 1,518 yes

    The middle row is the entire point. A status-code check scores it as a fix. That is what #182 cost us the first time, and it is now a measured row rather than a caution in a dispatch.

    The companion measurement is just as good: raw grep reports the 404 copy present on /docs/architecture and /privacy — healthy 200 pages — because it sits in the RSC payload either way. Stripped, both go to 0. The strip is not hygiene; it is the difference between a reading and a coin flip.

    The evidence took its strongest available form

    Not "404, and the copy is there" but byte-identical: sha256 fa59d702... on all seven bogus URLs, matching what /no-such-page has always served, with /privacy and /docs/architecture differing as controls. That is a much harder claim than the one I asked for, and it is the one that actually establishes these URLs now resolve to the same 404 rather than to seven separately-plausible ones.

    Prerender count 996/996 both sides, 577 HTML files — nothing left the enumeration to buy this.

    The matcher question came back with a measurement instead of a preference

    I asked to hear it if direction 1 turned out to be needed too. The answer is evidence, not agreement: after this fix there is no live consequence of the unanchored .*\..* exemption that the dev could produce. #208 is the only remainder and has zero instances today. So the adjudication now rests on a measurement rather than on my estimate of blast radius — which is the right way for it to hold.

    /favicon.ico moving from "307 into a 404" to "a clean 404" was flagged proactively as a behaviour change the card did not predict. Small, strictly better, and exactly the kind of thing that is worth saying out loud rather than letting a reviewer discover.

    The two findings are graded

    #207 — the .mdx copy/view links 404 on all six non-default locales, roughly 400 live dead links. Promoted to pm:queue / Bug with direction 2 adjudicated: the machine-facing markdown surface is already English-only by policy and by gate, so emitting the unprefixed URL conforms to that answer rather than inventing one. Direction 1 would reverse the policy through a routing change, and that belongs to the maintainer.

    #208 — latent, zero instances, correctly observation-class. Queued with the cheap containment adjudicated (reject a dotted slug at build time) rather than the matcher change.

    Both were found by sweeping a running server during verification, and both are invisible to every gate we have, because the gates read build output and the build output is correct in both cases.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions