Skip to content

fix(app-router): authorize the interception source route before rendering it#2733

Open
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:fix/interception-descendant-source-parity
Open

fix(app-router): authorize the interception source route before rendering it#2733
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:fix/interception-descendant-source-parity

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Warning

This is a high severity security fix. It closes an authorization bypass where a client header causes a middleware-guarded page to render and returns its RSC payload. Same class as #2732, which covered the Route Handler variant; this one covers pages, and it reaches targets that have a direct route, so it does not depend on that PR. Please treat as release-blocking.

Overview

Goal Make the interception source route clear the same middleware boundary a direct request to it would clear
Core change Before anything renders from a claimed interception source, run middleware for that source pathname and return its response if it denies
Key boundary Interception renders the source route's tree, so this request reaches two routes; middleware ran for only one of them
Expected impact Guarded source routes are denied. Requests with no interception context, and interception that resolves to the route already matched, keep their single middleware run

Why

Interception renders the source route's tree for the request. That means one request reaches two routes: the requested target, and the source named by the client. Middleware runs once, against the target's cleanPathname, before the source is even known.

The source pathname arrives in x-vinext-interception-context, which is a client header and cannot be authenticated (the same is true of Next-URL upstream). So an RSC request can name any route that satisfies the intercepting route's pattern, including a middleware-guarded one, and that route renders. The guard never sees the path it protects because middleware already ran for a different path.

There is no upstream behaviour to mirror, because the situation does not arise upstream. Next.js' generated interception rewrite has a destination fixed at build time to the intercepting route, and Next-URL appears only as a has gate. The Next.js client keeps the segments it already holds and applies just the intercepted slot, so the server never renders the source route for this request and there is no second route to authorize. It is vinext rendering the source tree that creates the extra boundary, so vinext owns authorizing it.

Area Principle / invariant What this PR changes
Interception source Any route this request renders must clear its own middleware boundary Source middleware runs before the source is used
Header trust An unauthenticated header may select a declared slot, not exempt a route from its guard The claimed source is authorized rather than trusted
Denial semantics Only a returned response is a denial Rewrites and normalized pathnames are treated as admitted

What changed

Scenario Before After
RSC target with a direct route, header names a guarded source page Source page renders, payload returned, guard never ran for it Source middleware runs, denial returned
Interception-only RSC target, header names a guarded source Source promoted and rendered Source middleware runs, denial returned
Header names an unguarded source Renders Unchanged, with an added middleware run for the source
Header absent, or source resolves to the already-matched route One middleware run Unchanged, still one run
Source middleware rewrites or normalizes the path n/a Treated as admitted; interception proceeds
Maintainer review path
  1. packages/vinext/src/server/app-rsc-handler.ts, the interceptionSourceMatch block. This is the whole change. Note the skip condition interceptionSourceMatch.route !== directPreActionMatch?.route: when the source resolves to the route already matched, resolveAppPageInterceptState returns none and no interception render happens, so there is no second route and no second run.
  2. tests/app-rsc-handler.test.ts for the denial case and the no-extra-run case.
Validation
  • Added regression coverage for a guarded source reached through a direct-route target, and for the absence of any extra middleware run when no interception context is sent.
  • Verified against the real createAppRscRouteMatcher driven through createAppRscHandler that both reachability paths are closed: a direct-route target (/photos/1) and an interception-only target (/hidden), each with a header naming a guarded /feed/secret, return the middleware denial and never dispatch the page. An unguarded source still renders.
  • Unit suites: app-rsc-handler, app-rsc-route-matching, app-server-action-execution, app-page-request, app-page-dispatch. 359 tests pass.
  • pnpm run check clean.
  • E2E: interception-dynamic-single-segment, interception-dynamic-segment, interception-dynamic-segment-middleware. 18 pass, 3 fail. Those 3 fail identically on a clean checkout of this base with no changes applied, so this PR introduces no new e2e failures. See the note below.
Pre-existing e2e failures on main, unrelated to this PR

Three tests in interception-dynamic-single-segment.spec.ts fail on unmodified main at the base of this branch:

  • preserves a deeply nested dynamic source page
  • preserves a consecutive dynamic source page
  • preserves a static multi-segment source page

All three assert that #children shows the descendant source page after an interception navigation; instead it shows the intercepting route's own page. The spec has not changed since #2231, so this looks like a regression from a later commit rather than a broken test. Worth its own issue. Flagging it here because it is the reason the e2e run below is not fully green, and because it is the coverage that would otherwise arbitrate the parity question in Non-goals.

Risk / compatibility

The material cost is an extra middleware execution, so it is called out rather than waved past.

  • Middleware runs twice on interception navigations that cross into a different route. User middleware side effects repeat: Set-Cookie, session rotation, rate-limit counters, logging. Requests with no interception context are unaffected, as is interception that resolves to the already-matched route.
  • The source run uses a throwaway middleware context, so its headers and status do not leak onto the target's response. The run decides admission only.
  • Public API: unchanged.
  • Build output: unchanged; this is request-time behaviour only.
  • Accepted divergence: Next.js does not re-run middleware for Next-URL. It does not need to, because it does not render the source route. Matching upstream here would mean leaving the boundary open.
Non-goals
  • Full upstream parity is not attempted. The parity-correct shape is to dispatch the intercepting route only, take source params from the intercepting-route pattern, and let the client keep its own children subtree, which would remove this boundary rather than guard it. In the matcher that is a small deletion (it also makes routeIndexes dead), but it depends on client-side segment retention that vinext does not currently do, and the e2e coverage that would validate it is currently red on main (see above). Left for a follow-up once that coverage is trustworthy.
  • Not a replacement for fix(app-router): reject Route Handlers as interception source routes #2732. That PR removes Route Handlers from interception source resolution entirely, which is the correct narrowing independent of authorization.

References

Reference Why it matters
generate-interception-routes-rewrites.ts Upstream rewrite: fixed destination, Next-URL only as a has gate, source never rendered
#2732 Route Handler variant of the same bypass
#2042 Introduced concrete descendant source resolution, which widens the set of reachable source routes
#2256 Extended source promotion to targets with no direct route

…ring it

Interception renders the source route's tree for the request, so one request
reaches two routes: the requested target and the claimed interception source.
Middleware runs once, for the target's cleanPathname, before the source is
known. The source pathname arrives in the `x-vinext-interception-context`
client header, so a crafted RSC request naming a middleware-guarded route
under the intercepting route causes that route to render and returns its
payload, while the guard never sees the path it protects. Applications using
middleware path checks as their authorization boundary lose it for any route
reachable as an interception source.

Run middleware for the claimed source pathname before anything renders from
it, and return its response when it denies. The check is skipped when the
source resolves to the route already matched for this request, which is also
the case where interception does not fire, so ordinary requests and requests
without an interception context keep their single middleware run.

Only a returned response counts as a denial. A rewritten or normalized
pathname means middleware admitted the source and merely routes it elsewhere,
which is a routing concern rather than an authorization one.

This boundary has no upstream counterpart because the situation does not arise
upstream: the generated interception rewrite targets the intercepting route
and the client keeps the segments it already holds, so Next.js never renders
the source route for this request and has no second route to authorize. It is
vinext rendering the source tree that creates the extra boundary.
@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@2733
npm i https://pkg.pr.new/create-vinext-app@2733
npm i https://pkg.pr.new/@vinext/types@2733
npm i https://pkg.pr.new/vinext@2733

commit: f827b78

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared f827b78 against base c9a4a84 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 132.5 KB 132.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ -0.0%
Dev server cold start vinext 2.73 s 2.71 s ⚫ -0.9%
Production build time vinext 2.85 s 2.86 s ⚫ +0.1%
RSC entry closure size (gzip) vinext 105.6 KB 105.7 KB ⚫ +0.1%
Server bundle size (gzip) vinext 179.8 KB 179.9 KB ⚫ +0.1%

View detailed results and traces

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

Source-route middleware authorization rebuilt its request from the Server Action request directly. That transferred the body stream and left action dispatch unable to read it.

Clone the body branch before changing the source URL so middleware and the action retain independent readable streams.
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