normalizeTrailingSlash built the redirect Location from the already
percent-decoded pathname. A character above U+00FF (e.g. a CJK slug or
emoji under trailingSlash:true) made the Headers constructor throw
'Cannot convert argument to a ByteString', caught by the outer handler
and surfaced as a 500 instead of a 308. Latin-1 characters such as
spaces did not throw but produced a malformed, un-percent-encoded
Location.
Percent-encode the redirect target before building the response,
encoding every character that is invalid raw in an RFC 3986 path while
preserving existing %xx escapes and / delimiters so already-encoded
delimiters like %23 are not double-encoded.
Fixes#1979
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app-router): refetch same-page search navigations
Same-page App Router navigations with a changed search string could replay a prefetched RSC payload instead of requesting the target payload. That diverges from Next.js, which refetches page segments for same-page navigations because search params are page inputs.
Bypass visited and prefetched RSC caches for navigate operations whose pathname is unchanged but search params differ. Add a prerendered production browser regression that ports the Next.js hash navigation request assertion.
* fix(app-router): hide rsc cache query from middleware
* test(app-router): mirror upstream hash scroll offsets
* fix(app-router): address review feedback on rsc query navigation
- Reuse fixture-scoped node_modules linking in the hash RSC spec instead of
symlinking repo-root node_modules, matching sibling production specs.
- Add an explicit assertion that no request leaked `_rsc` to the fixture
middleware (HTTP 599) for a clear failure mode.
- Document that the optimistic shell is intentionally not gated by
shouldBypassNavigationCache (detached commit, always superseded).
- Detect the RSC cache-busting param via the encoding-aware stripper so the
guard and stripper can never diverge on encoded-key edge cases.
* fix(app-router): share encoding-aware matcher for _rsc guard
Replace the before/after url.search comparison in
requestWithoutRscCacheBustingSearchParam with a dedicated
hasRscCacheBustingSearchParam helper that reuses the stripper's
isRscCacheBustingSearchPair matcher. This makes the guard and stripper share a
single source of truth and avoids spuriously rebuilding/normalizing requests
whose only difference is degenerate empty query pairs (e.g. ?a=1&&b=2).
* fix(app-router): preserve cf metadata when hiding _rsc from middleware
requestWithoutRscCacheBustingSearchParam reconstructed the userland request
with a bare new Request(url, source), which drops the Workers-specific cf
property and omits duplex: "half" for streaming bodies (throwing on
Node/undici). Add a cloneRequestWithUrl helper mirroring cloneRequestWithHeaders
that overrides the URL while preserving headers/cf and setting duplex, and route
the _rsc strip through it.
* fix(app-router): stop _rsc leaking to external rewrite proxies
The beforeFiles/afterFiles/fallback applyRewrite calls forwarded the original
request (with _rsc) to proxyExternalRequest, which reconstructs the upstream URL
from request.url and merges its query params — leaking the internal RSC
transport query to external rewrite destinations. Pass the _rsc-stripped
userlandRequest to all three calls, consistent with the middleware invariant.
Add a regression test that points an external rewrite at a loopback server and
asserts the forwarded URL drops _rsc while preserving userland query params.
* test(app-router): wait for idle network before tracking RSC requests
The query-param link is in the viewport and gets prefetched on load. On slower
runtimes (WebKit), that prefetch could land after rscRequestUrls.clear(), so it
was misattributed to the hash-only navigations and tripped the
"no with-query-param request" assertion. Wait for networkidle before clearing,
mirroring upstream's waitForIdleNetwork() in the ported Next.js test.
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): preserve encoded prerender params
App Router prerendered pages with encoded dynamic params could be bypassed in production and rendered again with decoded route params. This diverged from Next.js for generateStaticParams values like "sticks & stones".
The root cause was split across build and startup. The prerender render path only had the encoded URL, so normal route matching decoded the page params. After preserving encoded params for the render, production startup still seeded the ISR cache under the encoded artifact path while live requests lookup cache entries by the runtime-normalized pathname.
Carry trusted prerender params through the prerender-only request path, keep decoded params for dynamicParams validation, and seed prerendered App Router artifacts under the normalized runtime cache key while reading files from their encoded output path.
* chore(app-router): clean up prerender header internals
Reuse the shared record guard for prerender route param parsing and remove the local duplicate.
Keep Next.js INTERNAL_HEADERS as the exact upstream list, with vinext-only internal headers tracked separately but stripped by the same request pipeline. This fixes the CI unit failure while preserving the prerender header trust boundary.
Factor repeated Node-header conversion and forwarded-protocol handling in the production server to avoid local assertions and duplicate loops.
* fix(app-router): scope prerender params to route pattern
Bind the internal prerender route-param header to the route pattern that produced it so encoded render params cannot cross a rewrite boundary into a different dynamic route shape.
The App Router handler now only applies encoded prerender params when the payload routePattern matches the final matched route. Otherwise it falls back to the decoded params from normal route matching.
Tighten the header parser to validate the payload shape with Object.entries/Object.keys and add a regression for a prerendered source route rewritten to a dynamic target route with a different param name.
* fix(app-router): verify prerender params match route
Require trusted prerender route params to prove both the final route pattern and the final decoded matched params before using their encoded render values.
This prevents same-pattern rewrites from carrying stale prerender params into a different concrete route match, while keeping valid prerender renders encoded and static validation decoded.
Add handler regression coverage for same-pattern rewrites and helper coverage for catch-all array comparison.
* docs(app-router): clarify prerender route-param trust boundary
The prerender route-param readers and serializer carried implicit
coupling that read as security verification but actually delegated it.
A reader new to readTrustedPrerenderRouteParams could assume it verifies
the prerender secret, when verification happens once upstream at
prod-server's nodeToWebRequest boundary and every downstream read trusts
that. The double read-then-reattach in createAppRscHandler also depends
on the secret header surviving filterInternalHeaders, with no note of
that coupling.
Document the trust contract: secret is verified at the outer boundary,
downstream reads operate on already-trusted requests gated by
VINEXT_PRERENDER=1, and the secret header must stay out of the internal
strip list for the second read to succeed. Also document the
empty-params to null serialization contract.
No behavior change; comments only.
Trailing slash handling treated every non-api path the same on server redirects, so trailingSlash:true added slashes to file-looking catch-all routes and Pages Router imperative navigation skipped the client canonicalization path used by Link.
Next.js splits the invariant between route-manifest redirects and client resolveHref normalization: file-looking paths lose a trailing slash, non-file paths gain one, and queries stay attached to the canonical pathname. Share the server redirect decision across Pages Router runtimes and apply the same client helper in Router.push and Router.replace.
Adds focused regressions for catch-all dot segments, query preservation, .well-known exclusion, and Pages Router history writes.
* feat(server-action): add x-action-forwarded guard to prevent forwarding loops
Defensively implements the Next.js server action forwarding loop fix.
- Adds ACTION_FORWARDED_HEADER constant to headers.ts
- Adds x-action-forwarded to INTERNAL_HEADERS blocklist
- Adds guard to handleServerActionRscRequest and handleProgressiveServerActionRequest
- Returns 404 + x-nextjs-action-not-found when header is present
Ported from Next.js: vercel/next.js@20892dd
* test(e2e): add action-forward-loop fixtures and tests
- Adds /nextjs-compat/action-forward-loop fixture with middleware rewrite
- Adds /nextjs-compat/action-forward-loop-rewrite as rewrite target
- Adds error boundary component using unstable_isUnrecognizedActionError
- Adds E2E tests: middleware rewrite + action POST, not-found header contract
* test(e2e): fix action-forward-loop tests per review feedback
- page.tsx: use useActionState so action result renders into #action-result
- actions.ts: update runAction signature for useActionState compat
- Remove dead error-boundary.tsx (never exercised in single-worker model)
- Rename second test to match actual behavior (stale action ID -> 404)
* test(request-pipeline): add x-action-forwarded to INTERNAL_HEADERS snapshot
Middleware request-header overrides could rebuild forwarded external rewrite requests from only the override set. That drops cookie and authorization when middleware reports a partial override, so proxied upstream requests lose credentials.
Keep Next.js-compatible deletion semantics by default, and make credential preservation an explicit opt-in for external proxy boundaries. Cover both partial preservation and explicit credential deletion.
* fix: filter internal Next.js headers from inbound requests
Next.js strips internal headers (x-nextjs-data, x-matched-path,
x-now-route-matches, x-next-resume-state-length, and x-middleware-*)
via filterInternalHeaders() at the router-server entry point before
any handler or middleware sees the request. This prevents attackers
from forging these headers to influence routing or impersonate
internal data fetches.
vinext was not filtering any of these headers from inbound requests,
so all four reached middleware as user-controlled request headers.
Ported from Next.js INTERNAL_HEADERS list and filterInternalHeaders():
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/server-ipc/utils.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-server.ts
Added filterInternalHeaders() to server/request-pipeline.ts and wired
it into all request entry points:
- App Router handler (app-rsc-handler.ts)
- CF Worker App Router entry (app-router-entry.ts)
- Node prod-server (nodeToWebRequest + Pages Router path)
- Dev server Pages Router middleware path (index.ts)
- Generated Pages Router worker entry (deploy.ts)
Filtering runs before middleware, config matching, and routing so no
subsystem sees forged internal headers. The x-middleware-* headers are
stripped from requests but NOT from middleware responses — the
middleware protocol (which reads x-middleware-* from Response headers,
not request headers) continues to work correctly.
* fix: use safe Request rebuild instead of mutating headers directly
Request.headers is immutable in Cloudflare Workers / Miniflare /
workerd environments. The previous in-place mutation via
headers.delete() threw 'Can't modify immutable headers', causing
every request to 500 in the cloudflare-dev E2E suite.
Changed filterInternalHeaders() to always return a new Headers
copy (never mutates the input). Call sites that use request.headers
now rebuild the Request with the filtered headers, matching the
existing pattern in applyMiddlewareRequestHeaders() at
config-matchers.ts:495-502.
* fix: clone requests when filtering headers
* fix: clone Requests safely across runtimes
* fix: avoid type assertions in Request cf access
* fix: move header filtering from handler body to entry boundary
handleAppRscRequest() is an internal handoff point, not a trust
boundary. Filtering here broke the x-vinext-mw-ctx handoff in hybrid
app+pages dev mode because cloned Request objects could drop
coordination state from the connect handler, causing middleware to
run twice.
Moved filtering to the top of createAppRscHandler(), the true App
Router request boundary, before headersContextFromRequest() and
requestContextFromRequest() capture any context. This also fixes the
security gap where forged headers were captured in the request context
before filtering ran.
* fix: preserve x-vinext-mw-ctx when filtering internal headers in app-rsc-handler
srvx's NodeRequestHeaders reads from rawHeaders for iteration but falls back
to req.headers for .get() / .has(). In the dev server we add x-vinext-mw-ctx
to req.headers after the Request is built, so it is visible to .get() but lost
when filterInternalHeaders iterates. Read it BEFORE iterating so
applyForwardedMiddlewareContext can skip middleware and avoid double execution.
Fixes CI failure on PR #1056.
* fix: copy cf in both clone paths, add cloneRequestWithHeaders tests
cloneRequestWithHeaders now copies the Workers cf property in both
the primary (try) path and the fallback path. Added 8 unit tests
locking down method, URL, redirect, signal, body, cf, and combined
metadata preservation to prevent regression to the broken
URL-constructor pattern.
Six places in the request pipeline inlined a variation of:
pathname.replaceAll("\\", "/").startsWith("//")
to reject protocol-relative paths before they reach the trailing-slash
redirect emitter (which would otherwise echo them into a Location header).
The check handles literal delimiters but not their percent-encoded forms —
`/%5Cevil.com/` survives the guard, then `normalizePathnameForRouteMatchStrict`
re-encodes the decoded backslash back to `%5C` (preserving it as part of a
single path segment), so the 308 trailing-slash redirect ends up with
`Location: /%5Cevil.com`. Browsers percent-decode Location headers and
WHATWG URL treats backslash as forward slash, so the browser resolves that
to a protocol-relative host.
Factor the leading-segment shape check into a new `isOpenRedirectShaped`
helper in `server/request-pipeline.ts` that recognises literal (`//`, `/\\`)
and percent-encoded (`%5C`, `%2F`) variants, and swap each call site over
to it. Also add a defense-in-depth check in `normalizeTrailingSlash` so a
future caller that skips the upstream guard still can't emit a bad Location.
Updated call sites:
- server/request-pipeline.ts — guardProtocolRelativeUrl + normalizeTrailingSlash
- server/prod-server.ts — App Router and Pages Router Node handlers
- server/app-router-entry.ts — default Cloudflare Worker entry
- server/app-ssr-entry.ts — SSR environment entry
- index.ts — Pages Router Vite dev middleware
- deploy.ts — generated Pages Router Cloudflare Worker (inlined, kept in sync)
Regression tests in tests/request-pipeline.test.ts cover the encoded
variants plus the defense-in-depth path. Updates the pinned string
assertion in tests/deploy.test.ts that was checking for the old inlined
pattern.
* Treat `Origin: "null"` as an explicit cross-origin value
Also strip Transfer-Encoding
* Further hardening
* Add Vite HMR websocket token regression tests
Verify that websocket upgrades with an Origin header are rejected when the HMR token is missing or invalid, and accepted when the token matches the value exposed by @vite/client.
* Add clarifying comment and share a single dev server
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
* refactor: extract shared request handling into config-matchers and request-pipeline
Deduplicate ~250 lines of inline request-handling logic from the generated
App Router RSC entry (app-dev-server.ts) and Pages Router server entry
(index.ts) by importing from the canonical config-matchers.ts and a new
request-pipeline.ts module.
Changes:
- Create server/request-pipeline.ts with shared utilities:
guardProtocolRelativeUrl, stripBasePath, normalizeTrailingSlash,
validateCsrfOrigin, isOriginAllowed, validateImageUrl,
processMiddlewareHeaders
- app-dev-server.ts: replace 14 inline __-prefixed functions with imports
from config-matchers.ts (matchRedirect, matchRewrite, matchHeaders,
isExternalUrl, proxyExternalRequest, sanitizeDestination,
requestContextFromRequest) and request-pipeline.ts (validateCsrfOrigin,
validateImageUrl)
- index.ts: replace inline parseCookies, sanitizeDestinationLocal, and
matchConfigPattern with imports/re-exports from config-matchers.ts;
replace manual RequestContext construction with requestContextFromRequest()
- Update tests to reference imported function names instead of old inline
__-prefixed names
Security fixes:
- matchConfigPattern: eliminates chained .replace() divergence (CodeQL
flagged) in favor of config-matchers single-pass tokenizer
- proxyExternalRequest: uses AbortController+setTimeout (broader runtime
support) instead of AbortSignal.timeout()
- escapeHeaderSource: uses proper config-matchers implementation instead of
simplified chained .replace()
Closes#254
t status
* fix: address bonk review - remove dead applyTo param, dangling comment
* fix: remove dangling comments (parseCookies stub, sanitizeDestination stub)
* fix: remove bare block around __tsRedirect, add multiple trailing slash test
* fix: stricter /api prefix check for trailing slash normalization (matches only /api or /api/), add tests for edge cases
* test: update entry-template snapshots after merging main
---------
Co-authored-by: James <james@eli.cx>