From 08e3357480ffabb1623bd56735bc8961d3b4938c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 17:23:41 +0100 Subject: [PATCH] refactor: share gateway security path canonicalization --- src/gateway/security-path.test.ts | 13 ++-- src/gateway/security-path.ts | 71 ++++++++++++++------- src/gateway/server.plugin-http-auth.test.ts | 26 +------- 3 files changed, 57 insertions(+), 53 deletions(-) diff --git a/src/gateway/security-path.test.ts b/src/gateway/security-path.test.ts index 67b648246..f665efbfb 100644 --- a/src/gateway/security-path.test.ts +++ b/src/gateway/security-path.test.ts @@ -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"); }); diff --git a/src/gateway/security-path.ts b/src/gateway/security-path.ts index c1d4dd2e8..7b9fa493a 100644 --- a/src/gateway/security-path.ts +++ b/src/gateway/security-path.ts @@ -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, 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(); - 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(); + +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)), diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index a17e693fb..b6a75ea00 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -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 = {