Skip to content

Commit 850e95c

Browse files
committed
fix(route-rukles): reject out-of-scope proxy requests
1 parent e3e654c commit 850e95c

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

src/runtime/internal/route-rules.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { proxyRequest, redirect as sendRedirect, requireBasicAuth } from "h3";
1+
import { HTTPError, proxyRequest, redirect as sendRedirect, requireBasicAuth } from "h3";
22
import type { BasicAuthOptions, EventHandler, Middleware } from "h3";
33
import type { MatchedRouteRule, NitroRouteRules } from "nitro/types";
44
import { joinURL, withQuery, withoutBase } from "ufo";
@@ -49,6 +49,9 @@ export const proxy: RouteRuleCtor<"proxy"> = ((m) =>
4949
let targetPath = event.url.pathname + event.url.search;
5050
const strpBase = (m.options as any)._proxyStripBase;
5151
if (strpBase) {
52+
if (!isPathInScope(event.url.pathname, strpBase)) {
53+
throw new HTTPError({ status: 400, message: "Invalid request path" });
54+
}
5255
targetPath = withoutBase(targetPath, strpBase);
5356
}
5457
target = joinURL(target.slice(0, -3), targetPath);
@@ -96,3 +99,21 @@ export const basicAuth: RouteRuleCtor<"auth"> = /* @__PURE__ */ Object.assign(
9699
}) satisfies RouteRuleCtor<"auth">,
97100
{ order: -1 }
98101
);
102+
103+
// Check whether `pathname`, after canonicalization, stays within `base`.
104+
// Prevents match/forward differentials where an encoded traversal like `..%2f`
105+
// bypasses the `/**` scope at match time but escapes the base once the
106+
// upstream decodes `%2f` → `/` (GHSA-5w89-w975-hf9q).
107+
//
108+
// WHATWG URL keeps `%2F` and `%5C` opaque in paths, so we pre-decode those,
109+
// then let `new URL` resolve `.`/`..`/`%2E%2E` segments and normalize `\`.
110+
export function isPathInScope(pathname: string, base: string): boolean {
111+
let canonical: string;
112+
try {
113+
const pre = pathname.replace(/%2f/gi, "/").replace(/%5c/gi, "\\");
114+
canonical = new URL(pre, "http://_").pathname;
115+
} catch {
116+
return false;
117+
}
118+
return !base || canonical === base || canonical.startsWith(base + "/");
119+
}

test/unit/route-rules.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import { normalizeRouteRules } from "../../src/config/resolvers/route-rules.ts";
3+
import { isPathInScope } from "../../src/runtime/internal/route-rules.ts";
34

45
describe("normalizeRouteRules - swr", () => {
56
it("swr: true enables SWR", () => {
@@ -29,3 +30,44 @@ describe("normalizeRouteRules - swr", () => {
2930
expect(withFalse["/api/**"].cache).toBeUndefined();
3031
});
3132
});
33+
34+
// Regression for GHSA-5w89-w975-hf9q: an encoded traversal like `..%2f` must
35+
// not let a request escape a `/**` proxy scope once the upstream decodes it.
36+
describe("isPathInScope", () => {
37+
it("accepts in-scope paths", () => {
38+
expect(isPathInScope("/api/orders/list.json", "/api/orders")).toBe(true);
39+
expect(isPathInScope("/api/orders/", "/api/orders")).toBe(true);
40+
expect(isPathInScope("/api/orders", "/api/orders")).toBe(true);
41+
});
42+
43+
it("rejects encoded slash traversal (%2f)", () => {
44+
expect(isPathInScope("/api/orders/..%2fadmin%2fconfig.json", "/api/orders")).toBe(false);
45+
expect(isPathInScope("/api/orders/..%2Fadmin", "/api/orders")).toBe(false);
46+
});
47+
48+
it("rejects encoded backslash traversal (%5c)", () => {
49+
expect(isPathInScope("/api/orders/..%5cadmin", "/api/orders")).toBe(false);
50+
});
51+
52+
it("rejects double-encoded dot-segments (%2E%2E)", () => {
53+
expect(isPathInScope("/api/orders/%2E%2E%2Fadmin", "/api/orders")).toBe(false);
54+
});
55+
56+
it("rejects literal traversal above scope", () => {
57+
expect(isPathInScope("/api/orders/../admin", "/api/orders")).toBe(false);
58+
expect(isPathInScope("/api/orders/../../etc/passwd", "/api/orders")).toBe(false);
59+
});
60+
61+
it("keeps traversal confined within scope", () => {
62+
expect(isPathInScope("/api/orders/foo/../bar", "/api/orders")).toBe(true);
63+
expect(isPathInScope("/api/orders/foo%2f..%2fbar", "/api/orders")).toBe(true);
64+
});
65+
66+
it("does not confuse sibling prefix with scope", () => {
67+
expect(isPathInScope("/api/ordersX/list.json", "/api/orders")).toBe(false);
68+
});
69+
70+
it("allows anything for an empty base (catch-all /**)", () => {
71+
expect(isPathInScope("/anything/here", "")).toBe(true);
72+
});
73+
});

0 commit comments

Comments
 (0)