diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index d0162a18e2..a056395546 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -1,4 +1,4 @@ -import { proxyRequest, redirect as sendRedirect, requireBasicAuth } from "h3"; +import { HTTPError, proxyRequest, redirect as sendRedirect, requireBasicAuth } from "h3"; import type { BasicAuthOptions, EventHandler, Middleware } from "h3"; import type { MatchedRouteRule, NitroRouteRules } from "nitro/types"; import { joinURL, withQuery, withoutBase } from "ufo"; @@ -29,6 +29,9 @@ export const redirect: RouteRuleCtor<"redirect"> = ((m) => 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 = withoutBase(targetPath, strpBase); } target = joinURL(target.slice(0, -3), targetPath); @@ -49,6 +52,9 @@ export const proxy: RouteRuleCtor<"proxy"> = ((m) => 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 = withoutBase(targetPath, strpBase); } target = joinURL(target.slice(0, -3), targetPath); @@ -96,3 +102,22 @@ export const basicAuth: RouteRuleCtor<"auth"> = /* @__PURE__ */ Object.assign( }) satisfies RouteRuleCtor<"auth">, { order: -1 } ); + +// 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 +// 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 `\`. +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/test/unit/route-rules.test.ts b/test/unit/route-rules.test.ts index 29210731ca..a749dd6168 100644 --- a/test/unit/route-rules.test.ts +++ b/test/unit/route-rules.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { normalizeRouteRules } from "../../src/config/resolvers/route-rules.ts"; +import { isPathInScope } from "../../src/runtime/internal/route-rules.ts"; describe("normalizeRouteRules - swr", () => { it("swr: true enables SWR", () => { @@ -29,3 +30,45 @@ describe("normalizeRouteRules - swr", () => { expect(withFalse["/api/**"].cache).toBeUndefined(); }); }); + +// Regression for GHSA-5w89-w975-hf9q: an encoded traversal like `..%2f` must +// not let a request escape a `/**` proxy/redirect scope once the downstream +// 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); + }); +});