Skip to content

fix(app-router): reject Route Handlers as interception source routes - #2732

Open
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:fix/interception-route-handler-promotion
Open

fix(app-router): reject Route Handlers as interception source routes#2732
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:fix/interception-route-handler-promotion

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Warning

This is a high severity security fix. It closes an authorization bypass in the App Router RSC request path that lets an unauthenticated remote client execute a Route Handler which middleware path checks were intended to protect. Please treat this as release-blocking.

Overview

Goal Stop a client-supplied interception context from selecting a Route Handler as the route that gets dispatched
Core change findIntercept no longer resolves the claimed source pathname to a concrete descendant route when that route is a Route Handler, and falls back to the slot owner
Key boundary The interception source pathname is an unauthenticated request header, so it may select which slot owner renders, never which arbitrary route executes
Expected impact Route Handlers become unreachable through interception promotion. Descendant page routes and their dynamic source params are unchanged

Why

Route interception is gated on a client header. vinext reads x-vinext-interception-context; Next.js reads Next-URL. Neither is authenticated, and neither can be, because it is just the browser reporting its current pathname. That makes the header safe to use as a gate on an intercept the developer already declared, and unsafe to use as a selector for which route the server dispatches.

Next.js keeps to that distinction. generateInterceptionRoutesRewrites emits a rewrite whose destination is fixed at build time to the intercepting route's app path, gated by a has condition matching Next-URL against ^<interceptingRoute>(?:/.*)?$. Source params come from matching the header against the intercepting route pattern. Next.js never resolves Next-URL to a concrete route and never dispatches whatever it lands on.

vinext's findIntercept does resolve it. After the source pathname passes the descendants-allowed pattern gate, the matcher re-resolves it through the route trie and returns whichever concrete route it hits as sourceRouteIndex (added in #2042 so dynamic descendant source params survive). Since #2256, an RSC request whose path has no direct App route match promotes that resolved route into the request's match, which then flows into the normal dispatch branches.

Those two behaviours compose into a route confusion. Middleware has already run, for the requested target path. A request naming a target that has no direct route, plus a header naming a descendant of the intercepting route, promotes that descendant. When the descendant is a Route Handler, dispatch executes it. An application that authorizes route handlers with middleware path checks never sees the protected path, so the guard does not fire and the handler runs.

Area Principle / invariant What this PR changes
Interception source resolution An unauthenticated header may gate a declared intercept, not choose an arbitrary dispatch target Route Handler matches are skipped; resolution falls back to the slot owner
Route Handler reachability A route.ts has no page, layouts, or parallel slots, so it can never own or sit inside an interception source tree Route Handlers are no longer promotable as interception sources
Next.js parity The interception rewrite destination is the intercepting route, fixed at build time Falling back to the slot owner matches the upstream destination

What changed

Scenario Before After
RSC request to an interception-only target, header names a descendant Route Handler Handler is promoted and executed, having only run middleware for the target path Promotion resolves to the slot owner; the handler is unreachable
Header names a descendant page route Concrete descendant promoted, source params preserved Unchanged
Header names the slot owner exactly Slot owner promoted Unchanged
Header names a non-descendant, or is absent No intercept Unchanged
Lazy Route Handler whose module has not loaded yet Classified as a page, so promotable Classified as a handler via __loadRouteHandler, consistently before and after first load
Maintainer review path
  1. packages/vinext/src/server/app-rsc-route-matching.tsfindIntercept's concreteSourceRoute resolution. This is the whole behavioural change and the ownership decision: the guard sits in the shared matcher so every caller inherits it rather than each dispatch site guarding separately.
  2. tests/app-rsc-route-matching.test.ts — regression proof, including the lazy-handler classification case and the preserved page-descendant behaviour.
Validation
  • Added regression coverage asserting a descendant Route Handler resolves to the slot owner, that the same holds for a lazy handler before its module loads, and that descendant page routes still resolve concretely with their source params.
  • Confirmed the pre-fix behaviour by driving the real createAppRscRouteMatcher through createAppRscHandler: a request to an interception-only target with a header naming a middleware-protected Route Handler returned that handler's response, while a direct request to the same path was denied by middleware. After the change the handler is never dispatched.
  • Ran the interception-adjacent suites: app-rsc-route-matching, app-rsc-handler, app-server-action-execution, app-page-request, app-page-dispatch. 358 tests pass.
  • Ran pnpm run check (format, lint, types, Next.js type sync, shim types) clean.
  • No e2e fixture places a route.ts beneath an intercepting route, so no existing covered behaviour changes.
Commands
pnpm test tests/app-rsc-route-matching.test.ts tests/app-rsc-handler.test.ts \
  tests/app-server-action-execution.test.ts tests/app-page-request.test.ts \
  tests/app-page-dispatch.test.ts
  Test Files  5 passed (5)
       Tests  358 passed (358)

pnpm run check
  pass: All 2681 files are correctly formatted
  pass: Found no warnings, lint errors, or type errors in 1162 files
  Next.js types are in sync with next@16.2.7 (347 files)
  Public shim values match their vendored types (24 modules)
Risk / compatibility

Low risk, and the reason is that the removed capability has no legitimate use. Interception renders a page into a parallel slot, so a Route Handler could never have been a working interception source. Any request that previously reached a handler this way was already a route confusion.

  • Public API: unchanged. No signature, manifest, or config change.
  • Behaviour: narrows one branch of interception source resolution. Page descendants, exact slot-owner sources, dynamic source params, sibling intercepts, and the non-intercept paths are untouched.
  • Build output: unchanged. The manifest shape is the same; only request-time resolution differs.
  • Deliberate divergence: none introduced. This moves closer to the upstream rewrite semantics.
Non-goals
  • Descendant page promotion is left as is. A header naming a middleware-guarded page under the intercepting route can still promote it, and slot intercepts render the source route's own page as children. Closing that means deciding whether the concrete descendant resolution from fix(app-router): preserve dynamic interception source routes #2042 should exist at all, versus always dispatching the slot owner and taking source params from the intercepting-route pattern as Next.js does. That is a behavioural change to a shipped fix with e2e fixtures behind it and belongs in its own PR.
  • Re-running middleware for the claimed source path is deliberately not done. Next.js does not re-run middleware for Next-URL, and doing so would execute user middleware twice per interception navigation, duplicating side effects such as Set-Cookie, session rotation, and rate limiting.
  • No changeset is included; add one if this should ship in the next release.

References

Reference Why it matters
generate-interception-routes-rewrites.ts Upstream interception rewrite: fixed destination, Next-URL used only as a has gate
#2256 Introduced interception-only RSC target promotion, which put the resolved source route into the dispatch path
#2042 Introduced concrete descendant source resolution so dynamic source params survive

An RSC request whose path has no App route match is promoted to an
interception source route selected by the `x-vinext-interception-context`
header. `findIntercept` gates that header against the intercepting route's
pattern with descendants allowed, then resolves the claimed pathname through
the route trie and returns whichever concrete route it lands on. When that
route is a Route Handler, the promoted match reaches the handler dispatch
branch, so a crafted header executes a `route.ts` that merely lives under the
intercepting route, having only run middleware for the requested target path.
Applications that guard route handlers with middleware path checks therefore
lose that boundary: a request to an interception-only target returns the
protected handler's response.

A Route Handler has no page, layouts, or parallel slots, so it can never own
or sit inside an interception source tree. Skip it when resolving the concrete
descendant source route and fall back to the slot owner, which is the fixed
destination Next.js' generated interception rewrite targets. Descendant page
routes still resolve concretely so dynamic source params survive.

The interception source pathname remains unauthenticated, matching Next.js'
`Next-URL` gating. Promoting a descendant *page* route can still render a
middleware-guarded page for a target it does not own; closing that requires
deciding whether descendant promotion should exist at all, and is left
unchanged here.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 5e6b875

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba1cd9d89e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const matchedSourceParams = concreteSourceRoute
? concreteSourceRoute.params
: sourceRoute
? matchRoutePatternRaw(sourceParts, sourceRoute.patternParts)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve dynamic owner params when rejecting handler descendants

When the slot owner is dynamic and the claimed Route Handler is a deeper descendant—for example, owner /:locale/feed with source /en/feed/admin—this exact match receives extra path segments and returns null. The fallback consequently returns an empty sourceMatchedParams, and downstream matchInterceptRoute derives the promoted owner's params exclusively from that object, so the owner renders without locale. Extract the slot owner's params from the already-approved source prefix when falling back from a Route Handler.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 5e6b875 against base c9a4a84 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.5 KB ⚫ +0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ +0.0%
Dev server cold start vinext 2.82 s 2.78 s 🟢 -1.5%
Production build time vinext 2.93 s 2.91 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 179.8 KB 179.8 KB ⚫ +0.0%

View detailed results and traces

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

…ource

Falling back from a rejected Route Handler resolved the slot owner's params
with an exact pattern match against the claimed source pathname. That match
can never succeed on this path: the source was approved by the
descendants-allowed gate precisely because it carries extra segments beyond
the owner's pattern, so the match returns null and `sourceMatchedParams` ends
up empty. `matchInterceptRoute` derives the promoted owner's params solely
from that object, so a dynamic owner such as `/[locale]/feed` rendered with no
`locale` for a source of `/en/feed/admin`.

Take the owner's params from the prefix the source gate already approved when
the exact match fails. The same recovery covers a descendant source that
resolves to no concrete route at all, which had the identical gap before this
branch existed. The legacy manifest shape, which has no declared
`sourceMatchPattern`, still requires an exact match so its secondary gate keeps
rejecting unrelated sources.
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