Skip to content

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

Merged
james-elicx merged 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/interception-route-handler-promotion
Jul 31, 2026
Merged

fix(app-router): reject Route Handlers as interception source routes#2732
james-elicx merged 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/interception-route-handler-promotion

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 27, 2026

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 rejects Route Handler concrete sources and validates the final fallback owner before promotion; page owners and descendants remain unchanged
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; a Route Handler fallback owner is rejected rather than dispatched
Route Handler reachability A route.ts is not a renderable interception source, although the route graph may retain slots discovered beside it 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 a page slot owner exactly Slot owner promoted Unchanged
Header names a Route Handler slot owner Handler could be promoted and executed Interception is rejected; direct handler requests are 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 for descendant and fallback-owner Route Handlers, including lazy, static, dynamic, catch-all, and optional-catch-all shapes; 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. A full matcher-to-handler regression also proves a Route Handler slot owner remains directly reachable while forged interception promotion is rejected.
  • Ran the interception-adjacent suites: app-rsc-route-matching, app-rsc-handler, app-server-action-execution, app-page-request, app-page-dispatch. 364 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  364 passed (364)

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: 71d098f

@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".

Comment thread packages/vinext/src/server/app-rsc-route-matching.ts Outdated
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 71d098f against base 25dc2f3 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 134.3 KB 134.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 121.9 KB 121.9 KB ⚫ +0.0%
Dev server cold start vinext 3.01 s 2.86 s 🟢 -4.9%
Production build time vinext 3.21 s 3.17 s ⚫ -1.5%
RSC entry closure size (gzip) vinext 111.3 KB 111.3 KB ⚫ +0.0%
Server bundle size (gzip) vinext 189.0 KB 189.0 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.
@james-elicx

Copy link
Copy Markdown
Member

/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: reject Route Handlers as interception source routes

I traced the full request path and this is a correct, well-scoped fix for the described authorization bypass. Summary of what I verified:

The vulnerability is real and this closes it at the right layer. matchInterceptRoute in entries/app-rsc-entry.ts:1224 returns routes[intercept.sourceRouteIndex], which flows into interceptionPreActionMatch -> preActionMatch in app-rsc-handler.ts:889-896 and then into route-handler dispatch. Putting the guard inside findIntercept means every caller (the RSC promotion path, getSourceRoute in app-page-request.ts:717, server actions, sibling intercepts) inherits it, rather than each dispatch site guarding separately. This matches the ownership decision in the PR description.

Both handler cases are covered:

  • Concrete descendant handler (matchedSourceRoute.route is a handler) -> concreteSourceRoute is null, falls back to the slot owner index (app-rsc-route-matching.ts:296-304).
  • Fallback owner is itself a handler -> continue at line 312, so no handler index can ever be returned as sourceRouteIndex. This correctly covers the route.ts-with-adjacent-slots shape and sibling intercepts (whose sourceRouteIndex is the declaring route).

The Codex P2 (dynamic owner params) is already addressed. matchSlotOwnerSourceParams handles exactly the /:locale/feed <- /en/feed/admin case Codex raised: exact match returns null for the extra segment, then the descendants-allowed prefix branch calls extractRawParamsForMatchedRoute and yields { locale: "en" }. The keeps the slot owner's dynamic params when falling back from a descendant test asserts this directly, including the no-concrete-route and exact-source variants.

Parity and lazy classification are handled. isAppRouteHandlerRoute keys on both routeHandler != null and typeof __loadRouteHandler === "function", so a lazy handler is classified consistently before/after first load - the lazyMatcher test proves it.

Verification I ran locally:

  • vp test run tests/app-rsc-route-matching.test.ts tests/app-rsc-handler.test.ts -> 170 passed.
  • vp check on the changed source + test -> format/lint/types clean.

No blocking issues. Two minor, non-blocking notes below.

One process note: the PR description mentions no changeset is included. Per this repo's workflow, the changeset is generated from the Conventional Commit subject in CI, so the fix(app-router): subject produces a patch changeset automatically - nothing to add manually.

Comment thread packages/vinext/src/server/app-rsc-route-matching.ts
Comment thread packages/vinext/src/server/app-rsc-route-matching.ts
@ask-bonk

ask-bonk Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2732 (security fix rejecting Route Handlers as interception source routes) and posted a COMMENT review with two non-blocking inline notes.

Verdict: correct and well-scoped fix, no blocking issues.

What I verified by tracing the code:

  1. The vulnerability is real and the fix is at the right layer. The interception source route flows from matchInterceptRoute (app-rsc-entry.ts:1224) → preActionMatch (app-rsc-handler.ts:889-896) → route-handler dispatch. Placing the guard in findIntercept means all callers (RSC promotion, getSourceRoute, server actions, sibling intercepts) inherit it.

  2. Both handler cases are covered: concrete descendant handler falls back to the slot owner index; a handler owner triggers continue (line 312), so no handler index can ever be returned as sourceRouteIndex.

  3. The Codex P2 concern (dynamic owner params) is already fixed by matchSlotOwnerSourceParams, with a dedicated test for the /:locale/feed/en/feed/admin case.

  4. Lazy handler classification is consistent (keys on both routeHandler and __loadRouteHandler).

  5. Local verification: vp test run on both changed test files → 170 passed; vp check on changed files → clean.

The two inline comments are documentation notes (the continue semantics and the intentional params-preservation behavior change), not requested changes.

github run

@james-elicx
james-elicx merged commit 81db6e3 into cloudflare:main Jul 31, 2026
102 of 104 checks passed
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.

2 participants