Skip to content

fix(router): avoid repeated App path decoding#2556

Draft
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-app-encoded-path-routing
Draft

fix(router): avoid repeated App path decoding#2556
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-app-encoded-path-routing

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • decode App Router request path segments once at the request boundary
  • preserve normalized path state through middleware and rewrite routing
  • keep Node production, dev, and Cloudflare Workers route matching aligned
  • preserve residual encoded values in dynamic params and interception contexts

Test coverage

  • adds encoded-path cases to the existing App Router dev fixture and Playwright project
  • adds Node production coverage in the existing App Router project
  • adds Cloudflare Workers coverage in the existing Workers project
  • adds focused route matcher regressions for static routes, dynamic params, and interception contexts

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2556
npm i https://pkg.pr.new/create-vinext-app@2556
npm i https://pkg.pr.new/vinext@2556

commit: dc780b4

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared dc780b4 against base 75b9d1a using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.4 KB 126.6 KB ⚫ +0.1%
Client entry size (gzip) vinext 120.4 KB 120.6 KB ⚫ +0.1%
Dev server cold start vinext 2.23 s 2.23 s ⚫ -0.0%
Production build time vinext 2.51 s 2.50 s ⚫ -0.5%
RSC entry closure size (gzip) vinext 98.0 KB 98.8 KB ⚫ +0.8%
Server bundle size (gzip) vinext 164.6 KB 165.4 KB ⚫ +0.5%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(router): avoid repeated App path decoding

I traced the full request path for App Router across dev (index.ts), Node prod (prod-server.ts), and the generated RSC entry (which the Workers path also delegates to). This is a correct and well-targeted fix for a real double-decode / middleware-bypass class of bug. Verdict: approve with a couple of non-blocking observations.

What the fix does (and why it's sound)

The change has two complementary parts:

  1. Idempotent segment decode (routing/utils.ts) — broadening PATH_DELIMITER_REGEX from %(2f|23|3f|5c) to %[0-9a-f]{2} makes decodeRouteSegment/decodeRouteSegmentStrict re-encode every residual %XX escape after decodeURIComponent, not just delimiters. I verified this makes the function fully idempotent across a wide input set (normal UTF-8, %2F, double-encoded %2561, traversal shapes ..%252fadmin, malformed %C3/%2G, bare %): decode(x) === decode(decode(x)) for all of them. This is the crux — even code paths that still decode twice (e.g. findIntercept, the un-migrated matchRoute calls in app-server-action-execution.ts) now produce stable results.

  2. matchNormalizedRoute fast path (app-rsc-route-matching.ts + handler) — when cleanPathname is still the request-boundary-normalized value, route matching skips the redundant re-decode via splitPathSegments. The cleanPathnameIsNormalized flag is correctly flipped to false on every path that mutates cleanPathname to an un-normalized value: middleware rewrite (gated on middlewareResult.rewritten, which — verified in app-middleware.ts — is the only condition under which cleanPathname changes), and each beforeFiles/afterFiles/fallback/action rewrite. Because the boundary value is guaranteed decoded-once (via normalizePathnameForRouteMatchStrict in normalizeRscRequest), and decode is now idempotent, matchNormalizedRoute (skip decode) and matchRoute (decode once) are provably equivalent on normalized input.

Dev/prod/worker parity — verified

  • prod-server: reverting the RSC handler call from normalizedUrl back to rawUrl is the right move. Static-asset lookup and the /__vinext/ prerender gate still use the decoded pathname, but the RSC handler now decodes exactly once inside normalizeRscRequest instead of receiving an already-decoded path and decoding again. The prerender-secret gate stays consistent because %2F survives decoding in both prod-server and the handler.
  • dev (index.ts): passing pathname (decoded once at line 4731) as the new normalizedPathname arg prevents executeMiddlewareresolveMiddlewarePathname from decoding request.url a second time. This is the actual dev-side bug being closed — previously /%2561dmin could decode twice to /admin and hit a middleware matcher it should not.
  • workers: inherits the fix via the built RSC entry; covered by the new cloudflare-workers/ssr.spec.ts cases.

Security scenario is legit

The /%2561dmin case is a genuine middleware-bypass fix: Next.js matches middleware against the decode-once pathname (/%61dmin != /admin, so middleware does not block), and route matching must not decode again to /admin. The traversal cases (..%252fadmin, /api/health/..%252fadmin) correctly stay 404 because %252f remains encoded through decode+normalizePath, so .. can't traverse to /admin. I confirmed all three assertions by simulation.

I ran tests/app-rsc-route-matching.test.ts (27 pass), app-router-malformed-url + app-router-middleware-next-request (20 pass), entry-templates (41 pass), and tsc --noEmit (clean).

Non-blocking observations

  1. app-server-action-execution.ts still calls options.matchRoute(options.cleanPathname) (lines 1284, 1420) with an already-normalized cleanPathname, i.e. it double-decodes. This is now harmless thanks to the idempotency fix, but it's an inconsistency with the handler's matchCleanPathname optimization. Not a regression (pre-existing), and out of scope — but worth a follow-up so the "match a normalized path" contract is applied uniformly. A code comment noting these intentionally rely on decode idempotency would help future readers.

  2. The matchNormalizedRoute perf win is entirely dependent on decode idempotency for correctness. Since the idempotency fix alone would restore correctness, the fast path is a pure optimization. Consider a one-line comment at the matchCleanPathname definition stating that skipping decode is safe because the boundary value is already strict-normalized (so callers don't accidentally route an encoded path through it after a future refactor).

  3. api/health route doesn't exist in the fixture, so /api/health/..%252fadmin is really just exercising "unknown route → 404". That's fine for the traversal assertion, but if the intent was to prove traversal can't reach an existing sibling route, pointing the second traversal case at a real route (or the existing /api/hello) would make the test stronger.

Overall: clean, minimal, correct, and the idempotency insight makes the whole thing robust rather than relying on catching every call site. Nice work.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2556. Summary of my analysis:

Verdict: Approve (no blocking issues)

This is a correct, well-targeted fix for a double-decode / middleware-bypass bug class in App Router routing.

Core mechanism (verified sound):

  1. Idempotent decode — broadening PATH_DELIMITER_REGEX to re-encode all residual %XX escapes (not just delimiters) makes decodeRouteSegment fully idempotent. I confirmed decode(x) === decode(decode(x)) across normal, double-encoded, traversal, and malformed inputs. This makes even the code paths that still decode twice (findIntercept, action-execution) harmless.
  2. matchNormalizedRoute fast path — skips redundant re-decode when cleanPathname is still boundary-normalized. The cleanPathnameIsNormalized flag is correctly cleared on every path that changes cleanPathname (middleware rewrite is gated on rewritten, which I verified is the only case cleanPathname changes; all config rewrites flip it too).

Parity verified across dev (index.ts passes decoded pathname to middleware, closing the actual dev-side re-decode bug), Node prod (rawUrl restored so RSC handler decodes exactly once), and Workers (inherits via built entry).

Security assertions checked/%2561dmin → 404 (middleware bypass closed), ..%252fadmin traversal stays encoded → 404. All correct.

Tests I ran locally: route-matching (27), malformed-url + middleware-next-request (20), entry-templates (41), and tsc --noEmit — all clean.

Non-blocking notes flagged: (1) app-server-action-execution.ts still double-decodes but is now harmless — suggested a follow-up + comment; (2) suggested a comment documenting that the fast path's safety depends on the strict-normalized boundary invariant; (3) the /api/health traversal test hits a non-existent route, so it only proves "unknown → 404".

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(router): avoid repeated App path decoding

I independently re-verified this fix from the ground up: the decode regex change, the fast-path flag lifecycle, dev/prod/worker parity, and the security scenarios. Verdict: approve. The fix is correct, minimal, and closes a real double-decode / middleware-bypass bug class. A couple of non-blocking robustness notes below.

The core mechanism is sound

1. Idempotent segment decode (routing/utils.ts:172). Broadening PATH_DELIMITER_REGEX from %(2f|23|3f|5c) to %[0-9a-f]{2} re-encodes every residual %XX escape after decodeURIComponent, not just delimiters. I confirmed decode(x) === decode(decode(x)) across normal UTF-8 (caf%C3%A9, %E2%9C%93), delimiters (%2F), double-encoded (%2561), percent literals (100%25), and plus/space. The net effect on a double-encoded segment is identity: %2561dmin → (decode) %61dmin → (re-encode) %2561dmin.

2. Full-pipeline correctness (this is the actual bug being fixed). The decode-once-at-boundary + decode-again-at-param pipeline is what produced the bypass. I simulated the end-to-end path:

Input OLD (buggy) NEW (fixed)
/%2561dmin /%61dmin → param decode → admin /%2561dmin → param decode → %61dmin
/%2564ashboard /%64ashboarddashboard %64ashboard
/%61dmin (single) /admin /admin
/%64ashboard (single) /dashboard /dashboard

The OLD pipeline turned /%2561dmin into admin — a genuine middleware bypass. NEW keeps single-encoded paths decoding correctly while stopping double-encoded paths from collapsing to the target. This matches Next.js semantics: native URL.pathname does not decode, so Next.js middleware sees the raw double-encoded path; vinext decodes exactly once for matching and preserves residuals.

Parity verified across all three runtimes

  • prod-server (prod-server.ts:1432): reverting the RSC handler URL from normalizedUrl back to rawUrl is correct — static-asset lookup still uses decoded pathname, but the RSC handler now decodes exactly once inside normalizeRscRequest instead of receiving a pre-decoded path and decoding again.
  • dev (index.ts:4962): passing the once-decoded pathname as normalizedPathname to the Pages runner's runMiddleware prevents resolveMiddlewarePathname from decoding request.url a second time. This is the dev-side bug. App Router middleware was already safe (it passes normalizedPathname: cleanPathname in app-middleware.ts:249).
  • workers: inherits via the built RSC entry; covered by the new cloudflare-workers/ssr.spec.ts cases.

Fast-path flag lifecycle is complete

matchNormalizedRoute (skips the redundant decodeRouteSegment via splitPathSegments) is a pure optimization gated by cleanPathnameIsNormalized. I checked all six cleanPathname = assignments in app-rsc-handler.ts (642, 685, 721, 746, 963, 1009) — every un-normalizing mutation clears the flag. The middleware case (642) gates the reset on middlewareResult.rewritten, and I verified in app-middleware.ts:214-232 that cleanPathname is only reassigned when rewritten = true, so the invariant holds. Since the boundary value is strict-normalized and decode is idempotent, matchNormalizedRoute (decode-once) and matchRoute (decode-twice) are provably equivalent on normalized input — I confirmed identical param results for a%61, caf%C3%A9, a%2561, 50%2525, %2F, a%2Fb.

Verification run

  • tests/app-rsc-route-matching.test.ts + tests/shims.test.ts: 1212 pass
  • tsc --noEmit on packages/vinext: clean

Non-blocking notes

  1. Fragile cross-module invariant at app-rsc-handler.ts:642-643. Line 642 unconditionally assigns cleanPathname = middlewareResult.cleanPathname, but the flag reset on 643 is gated on rewritten. This is correct only because applyAppMiddleware maintains "cleanPathname changes ⟺ rewritten." If a future refactor normalized/mutated cleanPathname without setting rewritten, the fast path would silently route an un-normalized path through matchNormalizedRoute. Consider a value-based guard (if (cleanPathname !== normalized.cleanPathname) cleanPathnameIsNormalized = false) or a comment documenting the dependency.

  2. app-server-action-execution.ts still double-decodes via options.matchRoute(options.cleanPathname) on an already-normalized path. Now harmless thanks to idempotency, but inconsistent with the handler's matchCleanPathname optimization. Worth a follow-up + a comment noting these intentionally rely on decode idempotency.

  3. Traversal e2e tests only prove "unknown → 404." /foo/..%252fadmin and /api/health/..%252fadmin don't map to real traversal sources, so they exercise "unknown route → 404" rather than "traversal can't reach an existing sibling." The not.toContain("Protected admin content") assertion is the real security check and it holds; pointing one case at an existing route would make the intent sharper.

Clean, minimal, correct — the idempotency insight makes the whole thing robust rather than relying on catching every call site. Nice work.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2556.

Summary

Verdict: Approve — correct, minimal fix for a real double-decode / middleware-bypass bug class.

What I verified independently

  1. Idempotent decode (routing/utils.ts:172) — broadening PATH_DELIMITER_REGEX to %[0-9a-f]{2} makes decodeRouteSegment fully idempotent. Confirmed decode(x) === decode(decode(x)) across UTF-8, delimiters, double-encoded, percent literals.
  2. The actual bug — simulated the full pipeline (boundary decode + param decode). OLD behavior turned /%2561dminadmin (genuine bypass); NEW keeps it %61dmin while single-encoded /%61dmin still correctly → /admin. Matches Next.js semantics.
  3. Parity across prod (rawUrl restore), dev (normalizedPathname for Pages runner), and Workers.
  4. Fast-path flag lifecycle — checked all 6 cleanPathname assignments; every un-normalizing mutation clears cleanPathnameIsNormalized. Verified the middleware invariant in app-middleware.ts.
  5. Tests — 1212 pass (app-rsc-route-matching + shims), tsc --noEmit clean.

Non-blocking notes flagged

  • Fragile cross-module invariant at app-rsc-handler.ts:642-643 (flag reset gated on rewritten couples handler correctness to applyAppMiddleware) — suggested a value-based guard or comment.
  • app-server-action-execution.ts still double-decodes (now harmless via idempotency) — suggested follow-up + comment.
  • Traversal e2e tests only prove "unknown → 404"; could point at a real route to sharpen intent.

No blocking issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(router): avoid repeated App path decoding

I reviewed the current state of the branch (the implementation has evolved considerably from the earlier PATH_DELIMITER_REGEX-broadening approach described in prior comments — it now uses a dedicated raw-matching path). I traced the full request pipeline across dev (index.ts), Node prod (prod-server.ts), the generated RSC entry (Workers inherits it), and the route matcher. Verdict: approve. The fix is correct and well-scoped. A few non-blocking notes below.

What the fix actually does now

Rather than making decodeRouteSegment idempotent, this version introduces a parallel raw matching path:

  1. matchRequestRoute + trieMatchRaw/matchRoutePatternRaw (app-rsc-route-matching.ts, route-trie.ts, route-pattern.ts) — route matching runs against the encoded requestCleanPathname split with splitPathSegments (no decode). Params are then finalized with a match-once contract: routeHandler routes get decodeMatchedParams (decode once), page routes get canonicalizeAppPageParams (decode→re-encode, i.e. kept encoded in canonical form). This matches Next.js, where route handlers receive decoded params but page params retain encoding.

  2. requestCleanPathname (app-rsc-request-normalization.ts) — the original encoded pathname with only basePath + .rsc stripped, threaded through so the RSC handler can route on the un-decoded path. matchCleanPathname() uses it only while cleanPathnameIsRequestPathname holds; the flag is cleared on every cleanPathname mutation (middleware rewrite gated on rewritten, and each beforeFiles/afterFiles/fallback/action rewrite). I checked all six assignment sites — coverage is complete.

  3. Middleware sees the encoded path (middleware-runtime.ts) — createNextRequest now builds NextRequest from raw url.pathname instead of normalizedPathname, while the matcher (matchPathname) still uses the decoded normalizedPathname. That is exactly Next.js semantics: the matcher decides whether middleware runs (decoded), but request.nextUrl.pathname reflects the encoded request. The new !hasBasePath(requestPathname, basePath) guard also correctly avoids double-prefixing basePath.

Parity — verified

  • prod-server: reverting the RSC-handler URL from normalizedUrl back to rawUrl is correct — static-asset lookup still uses decoded pathname, but the handler now decodes exactly once inside normalizeRscRequest.
  • dev (index.ts): the new configureServer middleware captures req.__vinextOriginalEncodedUrl before Vite normalizes, and middlewareUrl is threaded to runMiddleware(..., pathname). This closes the dev-side re-decode.
  • workers: inherits via the built RSC entry; covered by the new cloudflare-workers/ssr.spec.ts cases.

Security scenarios check out

The interesting interaction: /%2561dmin — the matcher decodes to /admin so the /admin middleware matcher fires, but the middleware body reads request.nextUrl.pathname === "/%61dmin" (encoded), so pathname === "/admin" is false → no block → falls through to matchRequestRoute("/%2561dmin") → 404. Internally consistent, and matches Next.js. I also confirmed the raw path is stricter on traversal: requestCleanPathname is not normalizePath-collapsed, so .. stays a literal segment and cannot resolve to a sibling route (e.g. /foo/..%252fadmin → segments ["foo","..%252fadmin"] → 404). //-collapsing via splitPathSegments is harmless because the protocol-relative guard runs first and the normalized cleanPathname collapses identically.

Verification run (locally)

  • tests/app-rsc-route-matching.test.ts (27), app-rsc-handler.test.ts (123), app-page-request.test.ts (29), prerender-route-params.test.ts (24) — all pass
  • tests/shims.test.ts (1186) + static-image-emission.test.ts (5) — all pass
  • tests/middleware-runtime.test.ts + app-router-middleware-next-request.test.ts + middleware-runtime-trailing-slash.test.ts (45) — all pass
  • vp check on all changed source files — format/lint/types clean

Non-blocking notes

  1. Dead parameter: createNextRequest's normalizedPathname (middleware-runtime.ts:227). Since mwPathname now derives from url.pathname, the normalizedPathname argument is no longer read inside createNextRequest (only referenced in a stale comment at :238). It still passes lint because of the comment/call-site, but it is genuinely unused now. Consider dropping the parameter (and updating the comment) so the function signature doesn't imply it participates in mwPathname construction.

  2. middlewareUrl may retain a stripped .html suffix in dev (index.ts:4718). middlewareUrl is seeded from req.__vinextOriginalEncodedUrl (captured raw in configureServer), so it is not subject to the /index.html and .html stripping applied to url just above (lines 4711–4715). The _next/data branch explicitly resyncs middlewareUrl = url, but the .html case doesn't, so a /about.html request would hand middleware a nextUrl.pathname ending in .html. This is an unlikely path for App Router middleware, but it is an asymmetry with the non-encoded url and worth a resync (or a comment explaining why it's intentional).

  3. Traversal e2e cases still target non-existent routes. /foo/..%252fadmin and /api/health/..%252fadmin don't map to real traversal sources, so they mostly assert "unknown route → 404". The not.toContain("Protected admin content") check is the real security assertion and it holds; pointing one case at an existing sibling would sharpen intent.

Overall: correct, minimal, good test coverage across all three runtimes, and the raw-match-with-canonicalization approach cleanly separates "decode for matcher gating" from "route on encoded path." Nice work.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2556.

Verdict: Approve (no blocking issues)

The implementation has evolved from the earlier PATH_DELIMITER_REGEX approach described in prior comments. It now uses a dedicated raw-matching path rather than making decode idempotent.

What I verified:

  • matchRequestRoute + trieMatchRaw/matchRoutePatternRaw route on the encoded requestCleanPathname (no decode), then finalize params with a match-once contract — route handlers decode once, pages keep canonical-encoded params. Matches Next.js semantics.
  • cleanPathnameIsRequestPathname flag is cleared on all six cleanPathname mutation sites; coverage is complete.
  • Middleware (createNextRequest) now builds NextRequest from raw url.pathname while the matcher uses the decoded path — correct Next.js parity (matcher gates on decoded, nextUrl reflects encoded).
  • Parity across prod (rawUrl restore), dev (__vinextOriginalEncodedUrl capture + middlewareUrl threading), and Workers.
  • Security: /%2561dmin → 404 (bypass closed), traversal ..%252fadmin stays a literal segment → 404 (raw path is actually stricter than normalized).
  • Tests: ran route-matching, handler, page-request, prerender, shims, static-image, and 3 middleware test files locally — all 1400+ pass. vp check clean on all changed source.

Non-blocking notes flagged:

  1. createNextRequest's normalizedPathname param is now dead (only mwPathname from url.pathname) — suggested dropping it.
  2. Dev middlewareUrl may retain a stripped .html suffix (seeded from raw URL before .html stripping; unlike the _next/data branch it isn't resynced).
  3. Traversal e2e cases target non-existent routes, so they mostly prove "unknown → 404."

github run

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.

1 participant