diff --git a/src/runtime/internal/route-rules-utils.ts b/src/runtime/internal/route-rules-utils.ts new file mode 100644 index 0000000000..ef85a898b6 --- /dev/null +++ b/src/runtime/internal/route-rules-utils.ts @@ -0,0 +1,18 @@ +// 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/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index 4e392ad269..27444237e9 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( @@ -33,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); @@ -49,6 +54,9 @@ 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 }); + } 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); + }); +});