fix(middleware): preserve safe origins for double-slash redirects - #2766
Conversation
Same-origin absolute middleware redirects with a pathname beginning in two slashes were relativized into protocol-relative targets. Browsers could then navigate to the first path segment as an external host instead of remaining on the application origin. Keep open-redirect-shaped paths absolute and retain that invariant while applying trailing-slash policy. Boundary tests cover both HTTP Location and data-request x-nextjs-redirect responses.
|
@codex review |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9511cb4f09
ℹ️ 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".
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
Pages Router converted same-origin absolute data redirects back to app paths before history navigation. For a double-slash pathname, that exposed a protocol-relative target and made replaceState fail as a cross-origin mutation. Keep the original safe target when origin or basePath removal would expose an authority-shaped app path. The navigation regression test covers history state, the follow-up HTML fetch, and render completion.
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! 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". |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the double-slash open-redirect fix. The approach is sound and the fix is correctly split across both trust boundaries:
- Server (
relativizeLocation) now keeps same-origin absolute Locations absolute whenever the pathname isisOpenRedirectShaped, so removing the origin can never turnhttps://victim.example//evil.example/...into a protocol-relative//evil.example/.... I confirmed the trailing-slash refactor (settingloc.pathnamethen re-runningrelativizeLocation) is behaviorally identical to the oldnormalized + search + hashfor ordinary paths, and thatURL.pathname = "//evil.example/steal/"safely preserves the host (victim.example). - Client (
resolveLocalRedirectUrl) now returns the original same-origin target whentoSameOriginAppPath/ basePath stripping would expose a leading//, addressing the exacthistory.replaceStateSecurityError path Codex flagged.history.replaceStateand the follow-upfetchboth receive a same-origin absolute URL, so they stay on-origin. The backslash variant (/\evil.example) is normalized to//by the URL parser on both sides, so it's caught too.
All three x-nextjs-redirect / config-redirect consumers in router.ts (lines 2244, 2564, 2645) funnel through resolveLocalRedirectUrl, so the client guard covers every Pages Router path. The App Router relies on the shared server-side relativizeLocation fix plus browser-enforced same-origin fetch resolution, so it's covered without a separate client change.
Ran vp test run tests/middleware-runtime.test.ts tests/middleware-runtime-trailing-slash.test.ts locally: 34 passed.
One minor consistency note below (non-blocking).
| // would reinterpret as a different authority. Keep the original same-origin | ||
| // target instead; absolute and basePath-prefixed forms are both safe inputs | ||
| // to history navigation. | ||
| if (appPath.startsWith("//")) { |
There was a problem hiding this comment.
Minor consistency note (non-blocking): the server guard uses isOpenRedirectShaped, which also catches percent-encoded forms (/%2f..., /%5c...), but this client guard only checks appPath.startsWith("//"). I don't think this is exploitable on the client — for a Location like https://victim.example/%2fevil.example/steal, toSameOriginAppPath yields /%2fevil.example/steal (single leading slash), and browsers do not treat %2f as an authority delimiter in history.replaceState or fetch, so the navigation stays same-origin. The backslash form is already normalized to // by the URL parser, so it's covered here.
So behavior is correct, but the two boundaries now use different predicates for the "same" invariant. If you want them to stay obviously in sync, consider reusing isOpenRedirectShaped(appPath) here (it lives in server/open-redirect.ts and has no server-only deps) instead of the inline startsWith("//"), so a future change to the shaped-detection logic updates both sides together. Not required to merge.
|
Review posted successfully. Here's a summary of my findings: Review Summary — PR #2766Verdict: Approve (with one minor, non-blocking consistency note). The fix correctly closes a protocol-relative open-redirect ( Server (
Client ( Coverage — All three Tests — Ran Minor note (non-blocking): The server uses |
Summary
_next/dataconsumption, including History API mutation and the follow-up HTML fetch.Why
A safe absolute target such as
https://victim.example//evil.example/stealhas a pathname beginning with//. Removing the same origin turned it into//evil.example/steal, which browsers interpret as an external authority. The middleware runtime owns server emission, and the Pages Router owns client consumption, so both boundaries now refuse transformations that would change the authority.Validation
Locationandx-nextjs-redirectoutput.vp test run tests/shims.test.ts: 1,270 passed.vp test run tests/middleware-runtime.test.ts tests/middleware-runtime-trailing-slash.test.ts tests/request-pipeline.test.ts: 138 passed.vp check packages/vinext/src/server/middleware-runtime.ts packages/vinext/src/shims/router.ts tests/middleware-runtime.test.ts tests/shims.test.ts: formatting, lint, and types passed.vp run vinext#build: passed after both changes.Risk and compatibility
Ordinary same-origin redirect locations remain relative, and cross-origin locations remain absolute. The deliberate compatibility exception is limited to same-origin paths that match the existing open-redirect guard or become authority-shaped after client origin/basePath stripping. Those targets remain in their original safe form so their authority cannot change during server emission or client navigation.