fix(route-rules): prevent open redirect via protocol-relative url bypass#4236
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✅ Files skipped from review due to trivial changes (2)
📝 WalkthroughWalkthroughRedirect and proxy route rules now collapse multiple leading slashes in computed target paths (after base stripping) to a single slash; tests and a fixture were added and a devDependency was bumped. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 6/8 reviews remaining, refill in 10 minutes and 54 seconds.Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/internal/route-rules.ts (1)
29-37: 🛠️ Refactor suggestion | 🟠 MajorNormalize
targetPathafter the strip-base branch.
targetPathalready starts from the raw request path, so keeping the collapse insideif (strpBase)can still miss cases where the matched wildcard rule has an empty strip base. ApplyingcollapseLeadingSlashes()immediately beforejoinURL()for both branches closes that edge case and simplifies the flow.♻️ Suggested adjustment
if (target.endsWith("/**")) { let targetPath = event.url.pathname + event.url.search; const strpBase = (m.options as any)._redirectStripBase; if (strpBase) { if (!isPathInScope(event.url.pathname, strpBase)) { throw new HTTPError({ status: 400 }); } - targetPath = collapseLeadingSlashes(withoutBase(targetPath, strpBase)); + targetPath = withoutBase(targetPath, strpBase); } + targetPath = collapseLeadingSlashes(targetPath); target = joinURL(target.slice(0, -3), targetPath); }if (target.endsWith("/**")) { let targetPath = event.url.pathname + event.url.search; const strpBase = (m.options as any)._proxyStripBase; if (strpBase) { if (!isPathInScope(event.url.pathname, strpBase)) { throw new HTTPError({ status: 400 }); } - targetPath = collapseLeadingSlashes(withoutBase(targetPath, strpBase)); + targetPath = withoutBase(targetPath, strpBase); } + targetPath = collapseLeadingSlashes(targetPath); target = joinURL(target.slice(0, -3), targetPath); }Also applies to: 52-60
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/runtime/internal/route-rules.ts` around lines 29 - 37, The code currently only calls collapseLeadingSlashes(targetPath) inside the strpBase branch which misses cases when strip-base is empty; always normalize targetPath before joining. After computing targetPath (and after optionally setting it via withoutBase when strpBase is truthy and validating with isPathInScope), call collapseLeadingSlashes(targetPath) unconditionally immediately before the joinURL call that constructs target (the same change should also be applied to the similar block that ranges around the later 52-60 lines). Ensure you reference and update uses of targetPath, strpBase, withoutBase, collapseLeadingSlashes, isPathInScope, and joinURL so the normalized path is always passed to joinURL.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/tests.ts`:
- Around line 321-328: Add a mirrored regression for the proxy path: call
callHandler with the proxy route that mirrors the redirect test (e.g. use url
"/rules/proxy/legacy//evil.com" similar to the existing redirect case), capture
the response (name it like proxy), assert the status matches the expected proxy
status (same as redirect, e.g. 307), and add the same header checks on
proxy.headers.location — assert it does not match /^\/\// and equals "/evil.com"
so the proxy branch is covered by the same security regression test; place the
new assertions alongside the existing redirect assertions in the same test block
referencing callHandler and proxy (or proxyResponse) to locate the change.
---
Outside diff comments:
In `@src/runtime/internal/route-rules.ts`:
- Around line 29-37: The code currently only calls
collapseLeadingSlashes(targetPath) inside the strpBase branch which misses cases
when strip-base is empty; always normalize targetPath before joining. After
computing targetPath (and after optionally setting it via withoutBase when
strpBase is truthy and validating with isPathInScope), call
collapseLeadingSlashes(targetPath) unconditionally immediately before the
joinURL call that constructs target (the same change should also be applied to
the similar block that ranges around the later 52-60 lines). Ensure you
reference and update uses of targetPath, strpBase, withoutBase,
collapseLeadingSlashes, isPathInScope, and joinURL so the normalized path is
always passed to joinURL.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 88dd9712-556a-4881-8ad0-cdbcb0dc5761
📒 Files selected for processing (3)
src/runtime/internal/route-rules.tstest/fixture/nitro.config.tstest/tests.ts
| // Regression test for GHSA-9phm-9p8f-hw5m: a leading `//` after the | ||
| // wildcard prefix must not be forwarded as a protocol-relative URL. | ||
| const legacy = await callHandler({ | ||
| url: "/rules/redirect/legacy//evil.com", | ||
| }); | ||
| expect(legacy.status).toBe(307); | ||
| expect(legacy.headers.location).not.toMatch(/^\/\//); | ||
| expect(legacy.headers.location).toBe("/evil.com"); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Please add the equivalent proxy regression.
The runtime change hardens proxy alongside redirect, but this test only covers the redirect path. A matching proxy fixture/assertion would keep the second security-sensitive branch from regressing silently.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@test/tests.ts` around lines 321 - 328, Add a mirrored regression for the
proxy path: call callHandler with the proxy route that mirrors the redirect test
(e.g. use url "/rules/proxy/legacy//evil.com" similar to the existing redirect
case), capture the response (name it like proxy), assert the status matches the
expected proxy status (same as redirect, e.g. 307), and add the same header
checks on proxy.headers.location — assert it does not match /^\/\// and equals
"/evil.com" so the proxy branch is covered by the same security regression test;
place the new assertions alongside the existing redirect assertions in the same
test block referencing callHandler and proxy (or proxyResponse) to locate the
change.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
test/tests.ts (1)
581-588: Optional: assert proxy status for tighter regression signal.The body assertion is good; adding a status assertion (for example, 200) would make failures easier to localize if behavior changes later.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/tests.ts` around lines 581 - 588, Update the test "runtime proxy collapses leading slashes after wildcard prefix" to also assert the HTTP status returned by callHandler for a tighter regression signal: after calling callHandler({ url: "/rules/proxy/legacy//evil.com" }) inspect the returned object (the same place you read data) for the status field and add an expectation like expect(status).toBe(200) alongside the existing expect(data).toBe("evil.com"); this uses the same symbols used in the test (callHandler and the test title) so you can locate and modify the assertion in the same test block.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@test/tests.ts`:
- Around line 581-588: Update the test "runtime proxy collapses leading slashes
after wildcard prefix" to also assert the HTTP status returned by callHandler
for a tighter regression signal: after calling callHandler({ url:
"/rules/proxy/legacy//evil.com" }) inspect the returned object (the same place
you read data) for the status field and add an expectation like
expect(status).toBe(200) alongside the existing expect(data).toBe("evil.com");
this uses the same symbols used in the test (callHandler and the test title) so
you can locate and modify the assertion in the same test block.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4789d73d-4d9f-43f6-8156-861b83363b8c
📒 Files selected for processing (2)
test/fixture/nitro.config.tstest/tests.ts
✅ Files skipped from review due to trivial changes (1)
- test/fixture/nitro.config.ts
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/internal/route-rules.ts (1)
31-38:⚠️ Potential issue | 🔴 CriticalProtocol-relative bypass still possible when strip-base is enabled
Line 36 and Line 61 only normalize
//in theelsebranch. IfstrpBaseis set andwithoutBase(...)yields atargetPathstarting with//, normalization is skipped, so redirect/proxy can still receive protocol-relative style paths.Suggested fix
if (strpBase) { if (!isPathInScope(event.url.pathname, strpBase)) { throw new HTTPError({ status: 400 }); } targetPath = withoutBase(targetPath, strpBase); - } else if (targetPath.startsWith("//")) { + } + if (targetPath.startsWith("//")) { targetPath = targetPath.replace(/^\/+/, "/"); }if (strpBase) { if (!isPathInScope(event.url.pathname, strpBase)) { throw new HTTPError({ status: 400 }); } targetPath = withoutBase(targetPath, strpBase); - } else if (targetPath.startsWith("//")) { + } + if (targetPath.startsWith("//")) { targetPath = targetPath.replace(/^\/+/, "/"); }Also applies to: 56-63
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/runtime/internal/route-rules.ts` around lines 31 - 38, When strip-base (strpBase) is set, targetPath returned by withoutBase(event...) may still begin with "//" and currently only the else branch normalizes leading slashes, leaving a protocol-relative bypass; update the logic around strpBase, isPathInScope, withoutBase and the subsequent targetPath handling so that leading-slash normalization (targetPath.replace(/^\/+/, "/")) is always applied regardless of whether the strpBase branch ran (i.e., move or duplicate the normalization so both the strpBase path and the non-strpBase branch normalize targetPath), and apply the same change to the second similar block that handles targetPath (the block using the same startsWith("//") check).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/runtime/internal/route-rules.ts`:
- Around line 31-38: When strip-base (strpBase) is set, targetPath returned by
withoutBase(event...) may still begin with "//" and currently only the else
branch normalizes leading slashes, leaving a protocol-relative bypass; update
the logic around strpBase, isPathInScope, withoutBase and the subsequent
targetPath handling so that leading-slash normalization
(targetPath.replace(/^\/+/, "/")) is always applied regardless of whether the
strpBase branch ran (i.e., move or duplicate the normalization so both the
strpBase path and the non-strpBase branch normalize targetPath), and apply the
same change to the second similar block that handles targetPath (the block using
the same startsWith("//") check).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0e6bb393-25f0-4056-9f88-982ee3c8d09b
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (2)
package.jsonsrc/runtime/internal/route-rules.ts
✅ Files skipped from review due to trivial changes (1)
- package.json
commit: |
Backport of #4236 (GHSA-9phm-9p8f-hw5m). A leading `//` after the wildcard prefix (e.g. `/legacy//evil.com` matched by `/legacy/**: { redirect: "/**" }`) was emitted verbatim, yielding a protocol-relative `Location: //evil.com` that browsers resolve against the current scheme. The proxy rule had the symmetric issue. Bumps `ufo` to `^1.6.4` (collapses leading slashes inside `withoutBase`) and adds an inline collapse for the edge case where the rule itself is `/**` and `_*StripBase` is empty.
A
redirectorproxyroute rule with a/**wildcard target was vulnerable to an open redirect via protocol-relative URLs (GHSA-9phm-9p8f-hw5m).Given:
A request to
/legacy//evil.comproducedLocation: //evil.com— a protocol-relative URL the browser resolves against the current scheme, redirecting to an attacker-controlled host. Theproxyrule has the symmetric problem (forwarding to an unintended internal path).Internally,
withoutBase("/legacy//evil.com", "/legacy")returned"//evil.com", andjoinURL("", "//evil.com")emitted it verbatim.Fix
Two parts:
Bump
ufoto^1.6.4— unjs/ufo@5cd9e67 collapses any run of leading slashes to a single/insidewithoutBase, so the typical/scope/**case now yieldsLocation: /evil.com.Inline collapse in the runtime for the edge case where the rule path itself is
/**(_redirectStripBase/_proxyStripBaseis"", sowithoutBaseis never called and the raw pathname flows intojoinURL("", …)). Leading//is stripped before joining.Includes regression tests for both the redirect and proxy paths.