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
18 changes: 18 additions & 0 deletions src/runtime/internal/route-rules-utils.ts
Original file line number Diff line number Diff line change
@@ -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 + "/");
}
8 changes: 8 additions & 0 deletions src/runtime/internal/route-rules.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import defu from "defu";
import {
type H3Event,
createError,
eventHandler,
proxyRequest,
sendRedirect,
Expand All @@ -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(
Expand All @@ -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);
Expand All @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions test/unit/route-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading