Skip to content

fix(config): preserve destination path boundaries - #2589

Draft
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-config-capture-substitution
Draft

fix(config): preserve destination path boundaries#2589
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-config-capture-substitution

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • preserve encoded request path components during redirect and rewrite substitution
  • keep middleware matching normalization separate from request URL construction
  • align App and Pages behavior across development and production

Testing

  • targeted App and Pages development/production E2E coverage
  • config matcher, middleware, and request-pipeline tests
  • vp check
  • vp run vinext#build

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a1b100a

@github-actions

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

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a1b100a against base 8d0a18d 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.6 KB 126.9 KB ⚫ +0.2%
Client entry size (gzip) vinext 120.6 KB 120.9 KB ⚫ +0.3%
Dev server cold start vinext 2.10 s 2.09 s ⚫ -0.7%
Production build time vinext 2.19 s 2.19 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 98.6 KB 98.9 KB ⚫ +0.3%
Server bundle size (gzip) vinext 165.3 KB 165.6 KB ⚫ +0.2%

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: preserve destination path boundaries

Reviewed the config-matcher substitution hardening, the middleware/URL construction changes, and dev/Node-prod parity. The security logic in config-matchers.ts is solid — condition captures are consistently encoded per URL component, source-capture dot segments are re-encoded before WHATWG parsing can collapse them, and isValidSubstitutedExternalDestination fails closed on scheme/authority injection. Test coverage of the matcher is thorough.

I found one significant gap.

Cloudflare Workers Pages Router path is not covered (dev/prod/worker parity)

The fix threads a separately-computed normalizedPathname into runPagesRequest so a WHATWG new URL() parse can't collapse encoded dot segments before config redirect/rewrite matching. This is wired up in:

  • dev — index.ts (middlewareUrl + req.url)
  • Node prod — server/prod-server.ts:1861 passes normalizedPathname: pathname, and builds the Request from the encoded requestPathname

But the Cloudflare Workers Pages Router entry (server/pages-router-entry.ts) — the documented primary deployment target — was not updated:

  • pages-router-entry.ts:112-113 derives pathname via new URL(request.url).pathname, which collapses %2e%2e / %2e. / literal ...
  • pages-router-entry.ts:204 calls runPagesRequest(request, deps) without normalizedPathname, so pages-request-pipeline.ts:261 (deps.normalizedPathname ?? url.pathname) re-parses request.url and gets the already-collapsed path.

Concretely (new URL() behavior):

/proxy/%2e%2e/admin   -> /admin
/proxy/%2e./admin     -> /admin
/proxy/../admin       -> /admin
/proxy/%252e%252e/... -> /proxy/%252e%252e/... (double-encoded survives)

So on Workers, config redirects/rewrites and middleware-visible URLs see the collapsed path for the single-encoded traversal cases, diverging from dev and Node prod. wrapMiddlewareWithBasePath (line 185) also rebuilds the URL via mwUrl.pathname = ..., re-encoding again.

The App Router Workers path is fine — app-router-entry.ts:149-158 deliberately hands the raw request to the RSC handler, which owns the single decode/normalize pass, so encoding is preserved there.

This matches the parity class of bug called out in AGENTS.md ("check whether the same bug exists in ... cloudflare/worker-entry.ts"). The three new E2E specs exercise Pages dev (4173) and Node prod (4175) only; there is no Workers coverage for the encoded-dot cases, so CI won't catch this.

Suggested fix: in pages-router-entry.ts, compute the once-normalized pathname (mirroring prod-server.ts) and pass it as deps.normalizedPathname, and construct the Request handed to runPagesRequest from the encoded inbound pathname (not a new URL().pathname round-trip) so middleware sees the original encoded URL, matching Node prod.

Minor / non-blocking

  • pages-router-entry.ts:140-142 sets strippedUrl.pathname = stripped when stripping basePath, which re-encodes via WHATWG URL. Even once normalizedPathname is threaded through, the middleware-visible request.url on Workers can still differ from the raw inbound path for encoded-dot requests inside a basePath. Node prod avoids this by keeping a separate requestPathname string. Worth aligning in the same change.
  • The pre-Vite dispatch in index.ts:4431 only handles mixed encoded-dot segments (%2e. / .%2e), intentionally excluding fully-encoded %2e%2e (no literal . for Vite to treat as an extension). That scoping is correct; noting it so it isn't mistaken for an omission later.

Everything else — the encoding matrix, scheme-invariance guard, and the removal of normalizedPathname write-back in createNextRequest — looks correct and well-tested. The blocker is the missing Workers Pages Router parity.

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2589.

Summary

I reviewed PR #2589 (fix(config): preserve destination path boundaries) focusing on the config-matcher security logic and dev/prod/worker parity.

The good: The core hardening in config-matchers.ts is correct and well-tested — condition captures are encoded per URL component, source-capture dot segments are re-encoded before WHATWG parsing can collapse them (%252e%252e), and isValidSubstitutedExternalDestination fails closed on scheme/authority injection.

One significant gap (blocker): The fix threads a separate normalizedPathname into runPagesRequest so new URL() can't collapse encoded dot segments before config matching. This was wired into dev (index.ts) and Node prod (prod-server.ts), but not the Cloudflare Workers Pages Router entry (pages-router-entry.ts) — the documented primary deployment target:

  • pages-router-entry.ts:113 derives pathname via new URL(request.url).pathname (collapses %2e%2e/%2e./..)
  • pages-router-entry.ts:204 calls runPagesRequest without normalizedPathname

I verified the collapse behavior empirically. The App Router Workers path is fine (it hands the raw request to the RSC handler). The three new E2E specs only cover Pages dev + Node prod, so CI won't catch the Workers divergence. This is exactly the parity class AGENTS.md warns about.

Minor notes: basePath stripping in the worker re-encodes via strippedUrl.pathname =; and I confirmed the pre-Vite dispatch regex intentionally excludes fully-encoded %2e%2e (correct scoping).

I also confirmed the changed source files pass vp check and the pages-router + matchRewrite test suites pass.

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