fix(app-router): validate external RSC rewrites before proxying - #2754
Conversation
Out-of-basePath RSC requests claimed by basePath:false rewrites could reach external destinations before their missing or stale _rsc token was canonicalized. This bypassed cache-busting validation in every rewrite phase. Require every external rewrite call to validate a claimed request before proxy I/O. Regression coverage exercises GET and HEAD in beforeFiles, afterFiles, and fallback and verifies the upstream is never contacted for invalid tokens.
|
@codex review |
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
commit: |
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review
The change is well-targeted and correct. The core insight is sound: internal rewrites set filesystemRouteEligible = true and then hit a post-rewrite validateClaimedOutsideBasePathRsc() checkpoint (lines 754/827/1065/1115), but external rewrites proxy inline inside applyRewrite() and returned before any checkpoint could run. Adding a pre-proxy validation callback at the external-match boundary closes that gap consistently across all three rewrite phases.
Things I verified:
validateExternalRewriteRequestruns beforeclearRequestContext()andproxyExternalRequest(), and only readsrequest— no mutation or ordering hazard.- Passing
routeClaimed = truefrom external sites correctly mirrors the "route is now claimed" state that internal rewrites establish by flippingfilesystemRouteEligible. - The default-parameter form (
routeClaimed = filesystemRouteEligible) is evaluated at call time, so the existing no-arg checkpoint callers still observe the current mutable value — no closure staleness. - Validation uses the original
request(with_rscstill present), consistent with the other two checkpoints at lines 643 and 716. - The new test complements the existing valid-proxy test at line 1751: valid
_rscstill proxies (200), invalid gets the canonical 307. Theexpect(receivedUrls).toEqual([])assertion correctly proves zero upstream contact, and covering GET+HEAD across all three phases matchesresolveInvalidRscCacheBustingRequest's method gate. - Pages Router (
pages-request-pipeline.ts) does not participate in_rsccache-busting, so there is no dev/prod parity gap to mirror there — the mechanism is App Router-specific.
One potential follow-up (non-blocking): a middleware-driven external rewrite (proxyExternalMiddlewareRewrite in app-middleware.ts:164) of an outside-basePath RSC request appears to proxy immediately as well, before the line-754 checkpoint, and middleware rewrites also flip the route to eligible via didMiddlewareRewrite. That path is out of scope for this PR (config rewrites), and confirming whether Next.js validates _rsc ahead of a middleware external rewrite needs its own investigation against .nextjs-ref. Worth a tracking note rather than expanding this PR.
LGTM.
|
Review posted successfully. SummaryI reviewed PR #2754 ( Verdict: LGTM What the PR does: Internal config rewrites flip Verified correct:
One non-blocking follow-up noted: a middleware-driven external rewrite ( |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review
The code is correct, well-factored, and thoroughly tested. I verified all targeted suites pass locally (app-rsc-handler 142, app-rsc-cache-busting 37, entry-templates 52) and vp check is clean on the changed source files.
What I verified as correct:
- Every external-proxy path is now gated.
applyRewrite()is the soleproxyExternalRequestcall site for config rewrites, and all 6 call sites passvalidateExternalRewriteRequest. Validation runs beforeclearRequestContext()and only readsrequest, so no ordering/mutation hazard. - The default-parameter
routeClaimed = filesystemRouteEligibleis evaluated at call time, so the no-arg checkpoint at line 760 still observes the current mutablefilesystemRouteEligible. External rewrites forcerouteClaimed = true, correctly mirroring the "route claimed" state internal rewrites establish. validationResponseWithMiddlewareHeaderscaptureslocationbefore the middleware-header merge and restores it after — necessary becausemergeMiddlewareResponseHeadersusessetfor non-additive headers and would otherwise clobber the canonical 307Location.set-cookiemerging is correctly additive. The middleware test asserts both.restoreFlightHeadersonly clones when a header actually changes, so the no-override middleware path returns the request untouched.
Main concern — the PR title/body materially understates the scope. The title ("validate external RSC rewrites before proxying") and the "What changed" table describe only config beforeFiles/afterFiles/fallback. But this PR also:
- Adds validation to both middleware external-rewrite paths (forwarded-context and executed-middleware) in
app-middleware.ts— the ask-bonk review explicitly called the middleware path "out of scope for this PR," yet the diff clearly modifies it (that review appears to predate the middleware commits). - Changes which headers are forwarded to external origins from middleware rewrites. On
main, the middleware external path stripped Flight headers viarequestWithoutFlightHeaders(options.request). This PR switches toexternalRewriteRequest ?? options.request+restoreFlightHeaders, sorsc,next-router-state-tree,next-router-prefetch, etc. (and_rsc) are now forwarded to the external destination.
The "Risk / compatibility" section says "No public API or configuration shape changes" and "Validation runs only after an external rewrite matches" — neither mentions this header-forwarding change, which is the most behaviorally significant part of the diff. It does bring the middleware path into parity with the config path (which already used normalizedUserlandRequest on main), and forwarding _rsc is what lets the destination validate the request, so I believe the change is intentional and correct. But because it changes what headers reach arbitrary external origins, it warrants: (a) an explicit note in the PR body, and (b) confirmation against .nextjs-ref that Next.js forwards Flight headers to middleware external rewrite destinations (AGENTS.md requires verifying Next.js behavior for header/security-adjacent changes). If Next.js strips them, this is a divergence that should be a deliberate, documented decision.
None of this blocks merge on correctness grounds — the implementation is sound and CI-green. The asks are documentation/verification, not code changes.
Verdict: LGTM on the code; please update the PR description to cover the middleware path + Flight-header forwarding, and cite the Next.js reference for the header behavior.
| } | ||
| if (options.middlewareRequest) cancelRequestBody(options.middlewareRequest); | ||
| const externalRequest = requestWithoutFlightHeaders(options.request); | ||
| const externalRequest = options.externalRewriteRequest ?? options.request; |
There was a problem hiding this comment.
Behavioral change worth calling out in the PR body: on main this line was requestWithoutFlightHeaders(options.request), which stripped rsc / next-router-state-tree / next-router-prefetch / etc. before proxying to the external origin. With externalRewriteRequest ?? options.request (and restoreFlightHeaders in proxyExternalMiddlewareRewrite), those Flight headers plus _rsc are now forwarded to the destination.
This brings the middleware path into parity with the config-rewrite path (which already used normalizedUserlandRequest on main), and forwarding _rsc is what lets the destination validate the request — so it looks intentional. Please (1) mention it in the PR description, and (2) confirm against .nextjs-ref that Next.js forwards Flight headers to middleware external rewrite destinations, since this changes what reaches arbitrary external origins. Same applies to the forwarded-context branch at line 282's sibling around line 363.
| const headers = new Headers(request.headers); | ||
| let changed = false; | ||
|
|
||
| for (const name of FLIGHT_HEADERS) { |
There was a problem hiding this comment.
restoreFlightHeaders unconditionally restores the original Flight header values, overriding any middleware x-middleware-request-rsc/-next-router-* overrides (the "forwards valid RSC cache-busting params" test asserts the upstream sees the original rsc: 1 even though middleware set rsc: 0). That's a reasonable anti-tampering posture, but it means middleware cannot legitimately rewrite Flight headers for an external destination. Worth a one-line comment here documenting that this deliberately wins over middleware overrides, so a future reader doesn't "fix" it.
|
Review posted successfully. SummaryI reviewed PR #2754 and posted a COMMENT review with two inline comments. Verdict: LGTM on the code — but the PR description understates the scope. What the PR does (correctly): Closes a gap where invalid outside-basePath RSC GET/HEAD requests could be proxied to external rewrite destinations without What I verified as correct:
Main concern I raised (docs/verification, not code):
I also flagged that |
Overview
_rscand Flight headers for the external destination._rscvalues receive the canonical 307 consistently; valid external RSC rewrites retain the state their destination needs to render and validate Flight.Why
RSC cache-busting validation is delayed for requests outside the configured basePath until a
basePath: falserule or middleware rewrite claims them. Internal rewrites reached a delayed validation checkpoint, but external config and middleware rewrites returned their proxy response immediately. That allowed invalid GET and HEAD requests to bypass canonicalization.Middleware also receives a request with internal Flight state removed, as Next.js does. Before this change, vinext reused that stripped request for external proxying, so valid middleware rewrites dropped
_rscand the Flight headers at the destination. The proxy path now retains a separate downstream request and restores the original Flight headers after middleware request-header overrides, making those authenticated headers non-overridable.This matches Next.js v16.2.6's middleware adapter and external-rewrite fixture:
What changed
_rsctokenLocation_rscand Flight headers were stripped with the middleware-facing request_rsc,RSC, router-state, and prefetch headersMaintainer review path
packages/vinext/src/server/app-rsc-handler.tsfor original-request validation and separate middleware/downstream requests.packages/vinext/src/server/app-middleware.tsfor the two pre-proxy gates, safe response-header merge, and non-overridable Flight-header restoration.packages/vinext/src/entries/app-rsc-entry.tsfor generated middleware wiring.tests/app-rsc-handler.test.tsfor config phases plus normal and forwarded middleware coverage.Validation
Risk / compatibility
_rscor Flight transport headers.Location.