Skip to content

fix(route-rules): prevent open redirect via protocol-relative url bypass#4236

Merged
pi0 merged 4 commits into
mainfrom
fix/redirect-rule-collapse
Apr 29, 2026
Merged

fix(route-rules): prevent open redirect via protocol-relative url bypass#4236
pi0 merged 4 commits into
mainfrom
fix/redirect-rule-collapse

Conversation

@pi0
Copy link
Copy Markdown
Member

@pi0 pi0 commented Apr 29, 2026

A redirect or proxy route rule with a /** wildcard target was vulnerable to an open redirect via protocol-relative URLs (GHSA-9phm-9p8f-hw5m).

Given:

"/legacy/**": { redirect: "/**" }

A request to /legacy//evil.com produced Location: //evil.com — a protocol-relative URL the browser resolves against the current scheme, redirecting to an attacker-controlled host. The proxy rule has the symmetric problem (forwarding to an unintended internal path).

Internally, withoutBase("/legacy//evil.com", "/legacy") returned "//evil.com", and joinURL("", "//evil.com") emitted it verbatim.

Fix

Two parts:

  1. Bump ufo to ^1.6.4unjs/ufo@5cd9e67 collapses any run of leading slashes to a single / inside withoutBase, so the typical /scope/** case now yields Location: /evil.com.

  2. Inline collapse in the runtime for the edge case where the rule path itself is /** (_redirectStripBase / _proxyStripBase is "", so withoutBase is never called and the raw pathname flows into joinURL("", …)). Leading // is stripped before joining.

Includes regression tests for both the redirect and proxy paths.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nitro.build Ready Ready Preview, Comment Apr 29, 2026 11:03am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 12722563-95e5-4e81-bfde-08ca9c4b6b76

📥 Commits

Reviewing files that changed from the base of the PR and between eda1818 and 9bd19a9.

📒 Files selected for processing (2)
  • test/presets/netlify.test.ts
  • test/presets/vercel.test.ts
✅ Files skipped from review due to trivial changes (2)
  • test/presets/netlify.test.ts
  • test/presets/vercel.test.ts

📝 Walkthrough

Walkthrough

Redirect 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

Cohort / File(s) Summary
Route rule logic
src/runtime/internal/route-rules.ts
Post-process targetPath (after withoutBase) for redirect and proxy rules to collapse leading slash runs (e.g., ///) so protocol-relative-style or extra-leading-slash targets are not produced.
Test fixtures
test/fixture/nitro.config.ts
Add legacy routeRules entries for "/rules/redirect/legacy/**""/**" and "/rules/proxy/legacy/**""/api/wildcard/**" to exercise wildcard + leading-slash scenarios.
Tests
test/tests.ts
Add regression tests asserting legacy redirect yields Location: "/evil.com" (not //...) and legacy proxy does not forward leading // (expects proxied output "evil.com").
Platform presets tests
test/presets/netlify.test.ts, test/presets/vercel.test.ts
Update snapshots/expectations to include the new legacy redirect rule in Netlify and Vercel preset outputs (302/307 with proper rewrite).
Dev dependency
package.json
Bump ufo devDependency from ^1.6.3 to ^1.6.4.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows conventional commits format with type 'fix' and a clear, descriptive subject about preventing open redirect via protocol-relative URL bypass.
Description check ✅ Passed The description is directly related to the changeset, clearly explaining the vulnerability, the fix approach in two parts, and mentioning regression tests that are implemented.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/redirect-rule-collapse

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.

❤️ Share
Review rate limit: 6/8 reviews remaining, refill in 10 minutes and 54 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Normalize targetPath after the strip-base branch.

targetPath already starts from the raw request path, so keeping the collapse inside if (strpBase) can still miss cases where the matched wildcard rule has an empty strip base. Applying collapseLeadingSlashes() immediately before joinURL() 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6edbf84 and b4bd26c.

📒 Files selected for processing (3)
  • src/runtime/internal/route-rules.ts
  • test/fixture/nitro.config.ts
  • test/tests.ts

Comment thread test/tests.ts
Comment on lines +321 to +328
// 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");
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.

🛠️ 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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

📥 Commits

Reviewing files that changed from the base of the PR and between b4bd26c and 16f3230.

📒 Files selected for processing (2)
  • test/fixture/nitro.config.ts
  • test/tests.ts
✅ Files skipped from review due to trivial changes (1)
  • test/fixture/nitro.config.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🔴 Critical

Protocol-relative bypass still possible when strip-base is enabled

Line 36 and Line 61 only normalize // in the else branch. If strpBase is set and withoutBase(...) yields a targetPath starting 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

📥 Commits

Reviewing files that changed from the base of the PR and between 16f3230 and eda1818.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • package.json
  • src/runtime/internal/route-rules.ts
✅ Files skipped from review due to trivial changes (1)
  • package.json

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 29, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nitro@4236

commit: 9bd19a9

@pi0 pi0 merged commit bc1dd9d into main Apr 29, 2026
14 checks passed
@pi0 pi0 deleted the fix/redirect-rule-collapse branch April 29, 2026 11:10
pi0 added a commit that referenced this pull request Apr 29, 2026
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.
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