Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/runtime/internal/route-rules.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 + "/");
}
43 changes: 43 additions & 0 deletions test/unit/route-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
Loading