Skip to content

Commit 8d06a32

Browse files
authored
fix(route-rules): reject out-of-scope requests (#4223)
1 parent 0c5049e commit 8d06a32

3 files changed

Lines changed: 77 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Check whether `pathname`, after canonicalization, stays within `base`.
2+
// Prevents match/forward differentials where an encoded traversal like `..%2f`
3+
// bypasses the `/**` scope at match time but escapes the base once the
4+
// downstream (proxy upstream or redirect target) decodes `%2f` → `/`
5+
// (GHSA-5w89-w975-hf9q).
6+
//
7+
// WHATWG URL keeps `%2F` and `%5C` opaque in paths, so we pre-decode those,
8+
// then let `new URL` resolve `.`/`..`/`%2E%2E` segments and normalize `\`.
9+
export function isPathInScope(pathname: string, base: string): boolean {
10+
let canonical: string;
11+
try {
12+
const pre = pathname.replace(/%2f/gi, "/").replace(/%5c/gi, "\\");
13+
canonical = new URL(pre, "http://_").pathname;
14+
} catch {
15+
return false;
16+
}
17+
return !base || canonical === base || canonical.startsWith(base + "/");
18+
}

src/runtime/internal/route-rules.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import defu from "defu";
22
import {
33
type H3Event,
4+
createError,
45
eventHandler,
56
proxyRequest,
67
sendRedirect,
@@ -10,6 +11,7 @@ import type { NitroRouteRules } from "nitropack/types";
1011
import { createRouter as createRadixRouter, toRouteMatcher } from "radix3";
1112
import { getQuery, joinURL, withQuery, withoutBase } from "ufo";
1213
import { useRuntimeConfig } from "./config";
14+
import { isPathInScope } from "./route-rules-utils";
1315

1416
const config = useRuntimeConfig();
1517
const _routeRulesMatcher = toRouteMatcher(
@@ -33,6 +35,9 @@ export function createRouteRulesHandler(ctx: {
3335
let targetPath = event.path;
3436
const strpBase = (routeRules.redirect as any)._redirectStripBase;
3537
if (strpBase) {
38+
if (!isPathInScope(event.path.split("?")[0], strpBase)) {
39+
throw createError({ statusCode: 400 });
40+
}
3641
targetPath = withoutBase(targetPath, strpBase);
3742
}
3843
target = joinURL(target.slice(0, -3), targetPath);
@@ -49,6 +54,9 @@ export function createRouteRulesHandler(ctx: {
4954
let targetPath = event.path;
5055
const strpBase = (routeRules.proxy as any)._proxyStripBase;
5156
if (strpBase) {
57+
if (!isPathInScope(event.path.split("?")[0], strpBase)) {
58+
throw createError({ statusCode: 400 });
59+
}
5260
targetPath = withoutBase(targetPath, strpBase);
5361
}
5462
target = joinURL(target.slice(0, -3), targetPath);

test/unit/route-rules.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isPathInScope } from "../../src/runtime/internal/route-rules-utils";
3+
4+
// Regression for GHSA-5w89-w975-hf9q: an encoded traversal like `..%2f` must
5+
// not let a request escape a `/**` proxy scope once the upstream decodes it.
6+
describe("isPathInScope", () => {
7+
it("accepts in-scope paths", () => {
8+
expect(isPathInScope("/api/orders/list.json", "/api/orders")).toBe(true);
9+
expect(isPathInScope("/api/orders/", "/api/orders")).toBe(true);
10+
expect(isPathInScope("/api/orders", "/api/orders")).toBe(true);
11+
});
12+
13+
it("rejects encoded slash traversal (%2f)", () => {
14+
expect(
15+
isPathInScope("/api/orders/..%2fadmin%2fconfig.json", "/api/orders")
16+
).toBe(false);
17+
expect(isPathInScope("/api/orders/..%2Fadmin", "/api/orders")).toBe(false);
18+
});
19+
20+
it("rejects encoded backslash traversal (%5c)", () => {
21+
expect(isPathInScope("/api/orders/..%5cadmin", "/api/orders")).toBe(false);
22+
});
23+
24+
it("rejects double-encoded dot-segments (%2E%2E)", () => {
25+
expect(isPathInScope("/api/orders/%2E%2E%2Fadmin", "/api/orders")).toBe(
26+
false
27+
);
28+
});
29+
30+
it("rejects literal traversal above scope", () => {
31+
expect(isPathInScope("/api/orders/../admin", "/api/orders")).toBe(false);
32+
expect(isPathInScope("/api/orders/../../etc/passwd", "/api/orders")).toBe(
33+
false
34+
);
35+
});
36+
37+
it("keeps traversal confined within scope", () => {
38+
expect(isPathInScope("/api/orders/foo/../bar", "/api/orders")).toBe(true);
39+
expect(isPathInScope("/api/orders/foo%2f..%2fbar", "/api/orders")).toBe(
40+
true
41+
);
42+
});
43+
44+
it("does not confuse sibling prefix with scope", () => {
45+
expect(isPathInScope("/api/ordersX/list.json", "/api/orders")).toBe(false);
46+
});
47+
48+
it("allows anything for an empty base (catch-all /**)", () => {
49+
expect(isPathInScope("/anything/here", "")).toBe(true);
50+
});
51+
});

0 commit comments

Comments
 (0)