From b1bad1d9a6986918dcae5b573e08b3ebe9f19011 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 22 Apr 2026 10:45:08 +0200 Subject: [PATCH 1/2] fix(route-rules): reject out-of-scope proxy requests --- src/runtime/internal/route-rules-utils.ts | 17 ++++++++ src/runtime/internal/route-rules.ts | 8 ++++ test/unit/route-rules.test.ts | 51 +++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/runtime/internal/route-rules-utils.ts create mode 100644 test/unit/route-rules.test.ts diff --git a/src/runtime/internal/route-rules-utils.ts b/src/runtime/internal/route-rules-utils.ts new file mode 100644 index 0000000000..6e6bdbe0c4 --- /dev/null +++ b/src/runtime/internal/route-rules-utils.ts @@ -0,0 +1,17 @@ +// Check whether `pathname`, after canonicalization, stays within `base`. +// Prevents match/forward differentials where an encoded traversal like `..%2f` +// bypasses the `/**` scope at match time but escapes the base once the +// upstream decodes `%2f` → `/` (GHSA-5w89-w975-hf9q). +// +// WHATWG URL keeps `%2F` and `%5C` opaque in paths, so we pre-decode those, +// then let `new URL` resolve `.`/`..`/`%2E%2E` segments and normalize `\`. +export function isPathInScope(pathname: string, base: string): boolean { + let canonical: string; + try { + const pre = pathname.replace(/%2f/gi, "/").replace(/%5c/gi, "\\"); + canonical = new URL(pre, "http://_").pathname; + } catch { + return false; + } + return !base || canonical === base || canonical.startsWith(base + "/"); +} diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index 4e392ad269..de8f052cbd 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -1,6 +1,7 @@ import defu from "defu"; import { type H3Event, + createError, eventHandler, proxyRequest, sendRedirect, @@ -10,6 +11,7 @@ import type { NitroRouteRules } from "nitropack/types"; import { createRouter as createRadixRouter, toRouteMatcher } from "radix3"; import { getQuery, joinURL, withQuery, withoutBase } from "ufo"; import { useRuntimeConfig } from "./config"; +import { isPathInScope } from "./route-rules-utils"; const config = useRuntimeConfig(); const _routeRulesMatcher = toRouteMatcher( @@ -49,6 +51,12 @@ export function createRouteRulesHandler(ctx: { let targetPath = event.path; const strpBase = (routeRules.proxy as any)._proxyStripBase; if (strpBase) { + if (!isPathInScope(event.path.split("?")[0], strpBase)) { + throw createError({ + statusCode: 400, + statusMessage: "Invalid request path", + }); + } targetPath = withoutBase(targetPath, strpBase); } target = joinURL(target.slice(0, -3), targetPath); diff --git a/test/unit/route-rules.test.ts b/test/unit/route-rules.test.ts new file mode 100644 index 0000000000..f3919f2296 --- /dev/null +++ b/test/unit/route-rules.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { isPathInScope } from "../../src/runtime/internal/route-rules-utils"; + +// Regression for GHSA-5w89-w975-hf9q: an encoded traversal like `..%2f` must +// not let a request escape a `/**` proxy scope once the upstream decodes it. +describe("isPathInScope", () => { + it("accepts in-scope paths", () => { + expect(isPathInScope("/api/orders/list.json", "/api/orders")).toBe(true); + expect(isPathInScope("/api/orders/", "/api/orders")).toBe(true); + expect(isPathInScope("/api/orders", "/api/orders")).toBe(true); + }); + + it("rejects encoded slash traversal (%2f)", () => { + expect( + isPathInScope("/api/orders/..%2fadmin%2fconfig.json", "/api/orders") + ).toBe(false); + expect(isPathInScope("/api/orders/..%2Fadmin", "/api/orders")).toBe(false); + }); + + it("rejects encoded backslash traversal (%5c)", () => { + expect(isPathInScope("/api/orders/..%5cadmin", "/api/orders")).toBe(false); + }); + + it("rejects double-encoded dot-segments (%2E%2E)", () => { + expect(isPathInScope("/api/orders/%2E%2E%2Fadmin", "/api/orders")).toBe( + false + ); + }); + + it("rejects literal traversal above scope", () => { + expect(isPathInScope("/api/orders/../admin", "/api/orders")).toBe(false); + expect(isPathInScope("/api/orders/../../etc/passwd", "/api/orders")).toBe( + false + ); + }); + + it("keeps traversal confined within scope", () => { + expect(isPathInScope("/api/orders/foo/../bar", "/api/orders")).toBe(true); + expect(isPathInScope("/api/orders/foo%2f..%2fbar", "/api/orders")).toBe( + true + ); + }); + + it("does not confuse sibling prefix with scope", () => { + expect(isPathInScope("/api/ordersX/list.json", "/api/orders")).toBe(false); + }); + + it("allows anything for an empty base (catch-all /**)", () => { + expect(isPathInScope("/anything/here", "")).toBe(true); + }); +}); From f5f97a91d389618a68ea1161a6ea5f5f7daa5300 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 22 Apr 2026 10:52:13 +0200 Subject: [PATCH 2/2] fix(route-rules): reject out-of-scope redirect requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the scope check added for proxy rules. An encoded traversal like `..%2f` bypasses the `/**` scope at match time but can escape the base once the redirect target decodes `%2f` → `/`, letting a victim's browser reach a sibling scope on the redirect host. --- src/runtime/internal/route-rules-utils.ts | 3 ++- src/runtime/internal/route-rules.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/runtime/internal/route-rules-utils.ts b/src/runtime/internal/route-rules-utils.ts index 6e6bdbe0c4..ef85a898b6 100644 --- a/src/runtime/internal/route-rules-utils.ts +++ b/src/runtime/internal/route-rules-utils.ts @@ -1,7 +1,8 @@ // Check whether `pathname`, after canonicalization, stays within `base`. // Prevents match/forward differentials where an encoded traversal like `..%2f` // bypasses the `/**` scope at match time but escapes the base once the -// upstream decodes `%2f` → `/` (GHSA-5w89-w975-hf9q). +// downstream (proxy upstream or redirect target) decodes `%2f` → `/` +// (GHSA-5w89-w975-hf9q). // // WHATWG URL keeps `%2F` and `%5C` opaque in paths, so we pre-decode those, // then let `new URL` resolve `.`/`..`/`%2E%2E` segments and normalize `\`. diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index de8f052cbd..27444237e9 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -35,6 +35,9 @@ export function createRouteRulesHandler(ctx: { let targetPath = event.path; const strpBase = (routeRules.redirect as any)._redirectStripBase; if (strpBase) { + if (!isPathInScope(event.path.split("?")[0], strpBase)) { + throw createError({ statusCode: 400 }); + } targetPath = withoutBase(targetPath, strpBase); } target = joinURL(target.slice(0, -3), targetPath); @@ -52,10 +55,7 @@ export function createRouteRulesHandler(ctx: { const strpBase = (routeRules.proxy as any)._proxyStripBase; if (strpBase) { if (!isPathInScope(event.path.split("?")[0], strpBase)) { - throw createError({ - statusCode: 400, - statusMessage: "Invalid request path", - }); + throw createError({ statusCode: 400 }); } targetPath = withoutBase(targetPath, strpBase); }