Skip to content

Commit 135f762

Browse files
authored
fix(route-rules): reject out-of-scope requests (#4222)
1 parent 6b238f3 commit 135f762

2 files changed

Lines changed: 69 additions & 1 deletion

File tree

src/runtime/internal/route-rules.ts

Lines changed: 26 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";
@@ -29,6 +29,9 @@ export const redirect: RouteRuleCtor<"redirect"> = ((m) =>
2929
let targetPath = event.url.pathname + event.url.search;
3030
const strpBase = (m.options as any)._redirectStripBase;
3131
if (strpBase) {
32+
if (!isPathInScope(event.url.pathname, strpBase)) {
33+
throw new HTTPError({ status: 400 });
34+
}
3235
targetPath = withoutBase(targetPath, strpBase);
3336
}
3437
target = joinURL(target.slice(0, -3), targetPath);
@@ -49,6 +52,9 @@ export const proxy: RouteRuleCtor<"proxy"> = ((m) =>
4952
let targetPath = event.url.pathname + event.url.search;
5053
const strpBase = (m.options as any)._proxyStripBase;
5154
if (strpBase) {
55+
if (!isPathInScope(event.url.pathname, strpBase)) {
56+
throw new HTTPError({ status: 400 });
57+
}
5258
targetPath = withoutBase(targetPath, strpBase);
5359
}
5460
target = joinURL(target.slice(0, -3), targetPath);
@@ -96,3 +102,22 @@ export const basicAuth: RouteRuleCtor<"auth"> = /* @__PURE__ */ Object.assign(
96102
}) satisfies RouteRuleCtor<"auth">,
97103
{ order: -1 }
98104
);
105+
106+
// Check whether `pathname`, after canonicalization, stays within `base`.
107+
// Prevents match/forward differentials where an encoded traversal like `..%2f`
108+
// bypasses the `/**` scope at match time but escapes the base once the
109+
// downstream (proxy upstream or redirect target) decodes `%2f` → `/`
110+
// (GHSA-5w89-w975-hf9q).
111+
//
112+
// WHATWG URL keeps `%2F` and `%5C` opaque in paths, so we pre-decode those,
113+
// then let `new URL` resolve `.`/`..`/`%2E%2E` segments and normalize `\`.
114+
export function isPathInScope(pathname: string, base: string): boolean {
115+
let canonical: string;
116+
try {
117+
const pre = pathname.replace(/%2f/gi, "/").replace(/%5c/gi, "\\");
118+
canonical = new URL(pre, "http://_").pathname;
119+
} catch {
120+
return false;
121+
}
122+
return !base || canonical === base || canonical.startsWith(base + "/");
123+
}

test/unit/route-rules.test.ts

Lines changed: 43 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,45 @@ 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/redirect scope once the downstream
36+
// decodes it.
37+
describe("isPathInScope", () => {
38+
it("accepts in-scope paths", () => {
39+
expect(isPathInScope("/api/orders/list.json", "/api/orders")).toBe(true);
40+
expect(isPathInScope("/api/orders/", "/api/orders")).toBe(true);
41+
expect(isPathInScope("/api/orders", "/api/orders")).toBe(true);
42+
});
43+
44+
it("rejects encoded slash traversal (%2f)", () => {
45+
expect(isPathInScope("/api/orders/..%2fadmin%2fconfig.json", "/api/orders")).toBe(false);
46+
expect(isPathInScope("/api/orders/..%2Fadmin", "/api/orders")).toBe(false);
47+
});
48+
49+
it("rejects encoded backslash traversal (%5c)", () => {
50+
expect(isPathInScope("/api/orders/..%5cadmin", "/api/orders")).toBe(false);
51+
});
52+
53+
it("rejects double-encoded dot-segments (%2E%2E)", () => {
54+
expect(isPathInScope("/api/orders/%2E%2E%2Fadmin", "/api/orders")).toBe(false);
55+
});
56+
57+
it("rejects literal traversal above scope", () => {
58+
expect(isPathInScope("/api/orders/../admin", "/api/orders")).toBe(false);
59+
expect(isPathInScope("/api/orders/../../etc/passwd", "/api/orders")).toBe(false);
60+
});
61+
62+
it("keeps traversal confined within scope", () => {
63+
expect(isPathInScope("/api/orders/foo/../bar", "/api/orders")).toBe(true);
64+
expect(isPathInScope("/api/orders/foo%2f..%2fbar", "/api/orders")).toBe(true);
65+
});
66+
67+
it("does not confuse sibling prefix with scope", () => {
68+
expect(isPathInScope("/api/ordersX/list.json", "/api/orders")).toBe(false);
69+
});
70+
71+
it("allows anything for an empty base (catch-all /**)", () => {
72+
expect(isPathInScope("/anything/here", "")).toBe(true);
73+
});
74+
});

0 commit comments

Comments
 (0)