export type SecurityPathCanonicalization = { canonicalPath: string; candidates: string[]; malformedEncoding: boolean; rawNormalizedPath: string; }; const MAX_PATH_DECODE_PASSES = 3; function normalizePathSeparators(pathname: string): string { const collapsed = pathname.replace(/\/{2,}/g, "/"); if (collapsed.length <= 1) { return collapsed; } return collapsed.replace(/\/+$/, ""); } function normalizeProtectedPrefix(prefix: string): string { return normalizePathSeparators(prefix.toLowerCase()) || "/"; } function resolveDotSegments(pathname: string): string { try { return new URL(pathname, "http://localhost").pathname; } catch { return pathname; } } function normalizePathForSecurity(pathname: string): string { return normalizePathSeparators(resolveDotSegments(pathname).toLowerCase()) || "/"; } 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 buildCanonicalPathCandidates( pathname: string, maxDecodePasses = MAX_PATH_DECODE_PASSES, ): { candidates: string[]; malformedEncoding: boolean } { const candidates: string[] = []; const seen = new Set(); pushNormalizedCandidate(candidates, seen, pathname); let decoded = pathname; let malformedEncoding = false; for (let pass = 0; pass < maxDecodePasses; pass++) { let nextDecoded = decoded; try { nextDecoded = decodeURIComponent(decoded); } catch { malformedEncoding = true; break; } if (nextDecoded === decoded) { break; } decoded = nextDecoded; 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 { 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 = getNormalizedPrefixes(prefixes); if ( canonical.candidates.some((candidate) => normalizedPrefixes.some((prefix) => prefixMatch(candidate, prefix)), ) ) { return true; } if (!canonical.malformedEncoding) { return false; } return normalizedPrefixes.some((prefix) => prefixMatch(canonical.rawNormalizedPath, prefix)); } export const PROTECTED_PLUGIN_ROUTE_PREFIXES = ["/api/channels"] as const; export function isProtectedPluginRoutePath(pathname: string): boolean { return isPathProtectedByPrefixes(pathname, PROTECTED_PLUGIN_ROUTE_PREFIXES); }