refactor: share gateway security path canonicalization

This commit is contained in:
Peter Steinberger
2026-02-26 17:23:41 +01:00
parent 15e3e63705
commit 08e3357480
3 changed files with 57 additions and 53 deletions

View File

@@ -9,23 +9,24 @@ import {
describe("security-path canonicalization", () => {
it("canonicalizes decoded case/slash variants", () => {
expect(canonicalizePathForSecurity("/API/channels//nostr/default/profile/")).toEqual({
path: "/api/channels/nostr/default/profile",
canonicalPath: "/api/channels/nostr/default/profile",
candidates: ["/api/channels/nostr/default/profile"],
malformedEncoding: false,
rawNormalizedPath: "/api/channels/nostr/default/profile",
});
const encoded = canonicalizePathForSecurity("/api/%63hannels%2Fnostr%2Fdefault%2Fprofile");
expect(encoded.path).toBe("/api/channels/nostr/default/profile");
expect(encoded.canonicalPath).toBe("/api/channels/nostr/default/profile");
expect(encoded.candidates).toContain("/api/%63hannels%2fnostr%2fdefault%2fprofile");
expect(encoded.candidates).toContain("/api/channels/nostr/default/profile");
});
it("resolves traversal after repeated decoding", () => {
expect(canonicalizePathForSecurity("/api/foo/..%2fchannels/nostr/default/profile").path).toBe(
"/api/channels/nostr/default/profile",
);
expect(
canonicalizePathForSecurity("/api/foo/%252e%252e%252fchannels/nostr/default/profile").path,
canonicalizePathForSecurity("/api/foo/..%2fchannels/nostr/default/profile").canonicalPath,
).toBe("/api/channels/nostr/default/profile");
expect(
canonicalizePathForSecurity("/api/foo/%252e%252e%252fchannels/nostr/default/profile")
.canonicalPath,
).toBe("/api/channels/nostr/default/profile");
});

View File

@@ -1,5 +1,5 @@
export type SecurityPathCanonicalization = {
path: string;
canonicalPath: string;
candidates: string[];
malformedEncoding: boolean;
rawNormalizedPath: string;
@@ -31,32 +31,26 @@ function normalizePathForSecurity(pathname: string): string {
return normalizePathSeparators(resolveDotSegments(pathname).toLowerCase()) || "/";
}
function prefixMatch(pathname: string, prefix: string): boolean {
return (
pathname === prefix ||
pathname.startsWith(`${prefix}/`) ||
// Fail closed when malformed %-encoding follows the protected prefix.
pathname.startsWith(`${prefix}%`)
);
function pushNormalizedCandidate(candidates: string[], seen: Set<string>, value: string): void {
const normalized = normalizePathForSecurity(value);
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push(normalized);
}
export function canonicalizePathForSecurity(pathname: string): SecurityPathCanonicalization {
export function buildCanonicalPathCandidates(
pathname: string,
maxDecodePasses = MAX_PATH_DECODE_PASSES,
): { candidates: string[]; malformedEncoding: boolean } {
const candidates: string[] = [];
const seen = new Set<string>();
const pushCandidate = (value: string) => {
const normalized = normalizePathForSecurity(value);
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push(normalized);
};
pushCandidate(pathname);
pushNormalizedCandidate(candidates, seen, pathname);
let decoded = pathname;
let malformedEncoding = false;
for (let pass = 0; pass < MAX_PATH_DECODE_PASSES; pass++) {
for (let pass = 0; pass < maxDecodePasses; pass++) {
let nextDecoded = decoded;
try {
nextDecoded = decodeURIComponent(decoded);
@@ -68,20 +62,51 @@ export function canonicalizePathForSecurity(pathname: string): SecurityPathCanon
break;
}
decoded = nextDecoded;
pushCandidate(decoded);
pushNormalizedCandidate(candidates, seen, decoded);
}
return { candidates, malformedEncoding };
}
export function canonicalizePathVariant(pathname: string): string {
const { candidates } = buildCanonicalPathCandidates(pathname);
return candidates[candidates.length - 1] ?? "/";
}
function prefixMatch(pathname: string, prefix: string): boolean {
return (
pathname === prefix ||
pathname.startsWith(`${prefix}/`) ||
// Fail closed when malformed %-encoding follows the protected prefix.
pathname.startsWith(`${prefix}%`)
);
}
export function canonicalizePathForSecurity(pathname: string): SecurityPathCanonicalization {
const { candidates, malformedEncoding } = buildCanonicalPathCandidates(pathname);
return {
path: candidates[candidates.length - 1] ?? "/",
canonicalPath: candidates[candidates.length - 1] ?? "/",
candidates,
malformedEncoding,
rawNormalizedPath: normalizePathSeparators(pathname.toLowerCase()) || "/",
};
}
const normalizedPrefixesCache = new WeakMap<readonly string[], readonly string[]>();
function getNormalizedPrefixes(prefixes: readonly string[]): readonly string[] {
const cached = normalizedPrefixesCache.get(prefixes);
if (cached) {
return cached;
}
const normalized = prefixes.map(normalizeProtectedPrefix);
normalizedPrefixesCache.set(prefixes, normalized);
return normalized;
}
export function isPathProtectedByPrefixes(pathname: string, prefixes: readonly string[]): boolean {
const canonical = canonicalizePathForSecurity(pathname);
const normalizedPrefixes = prefixes.map(normalizeProtectedPrefix);
const normalizedPrefixes = getNormalizedPrefixes(prefixes);
if (
canonical.candidates.some((candidate) =>
normalizedPrefixes.some((prefix) => prefixMatch(candidate, prefix)),

View File

@@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import type { HooksConfigResolved } from "./hooks.js";
import { canonicalizePathVariant } from "./security-path.js";
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
import { withTempConfig } from "./test-temp-config.js";
@@ -87,30 +88,7 @@ function createHooksConfig(): HooksConfigResolved {
}
function canonicalizePluginPath(pathname: string): string {
let decoded = pathname;
for (let pass = 0; pass < 3; pass++) {
let nextDecoded = decoded;
try {
nextDecoded = decodeURIComponent(decoded);
} catch {
break;
}
if (nextDecoded === decoded) {
break;
}
decoded = nextDecoded;
}
let resolved = decoded;
try {
resolved = new URL(decoded, "http://localhost").pathname;
} catch {
resolved = decoded;
}
const collapsed = resolved.toLowerCase().replace(/\/{2,}/g, "/");
if (collapsed.length <= 1) {
return collapsed;
}
return collapsed.replace(/\/+$/, "");
return canonicalizePathVariant(pathname);
}
type RouteVariant = {