export type SecurityPathCanonicalization = { canonicalPath: string; candidates: string[]; decodePasses: number; decodePassLimitReached: boolean; malformedEncoding: boolean; rawNormalizedPath: string; }; const MAX_PATH_DECODE_PASSES = 32; 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[]; decodePasses: number; decodePassLimitReached: boolean; malformedEncoding: boolean; } { const candidates: string[] = []; const seen = new Set(); pushNormalizedCandidate(candidates, seen, pathname); let decoded = pathname; let malformedEncoding = false; let decodePasses = 0; for (let pass = 0; pass < maxDecodePasses; pass++) { let nextDecoded = decoded; try { nextDecoded = decodeURIComponent(decoded); } catch { malformedEncoding = true; break; } if (nextDecoded === decoded) { break; } decodePasses += 1; decoded = nextDecoded; pushNormalizedCandidate(candidates, seen, decoded); } let decodePassLimitReached = false; if (!malformedEncoding) { try { decodePassLimitReached = decodeURIComponent(decoded) !== decoded; } catch { malformedEncoding = true; } } return { candidates, decodePasses, decodePassLimitReached, 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, decodePasses, decodePassLimitReached, malformedEncoding } = buildCanonicalPathCandidates(pathname); return { canonicalPath: candidates[candidates.length - 1] ?? "/", candidates, decodePasses, decodePassLimitReached, malformedEncoding, rawNormalizedPath: normalizePathSeparators(pathname.toLowerCase()) || "/", }; } export function hasSecurityPathCanonicalizationAnomaly(pathname: string): boolean { const canonical = canonicalizePathForSecurity(pathname); return canonical.malformedEncoding || canonical.decodePassLimitReached; } 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; } // Fail closed when canonicalization depth cannot be fully resolved. if (canonical.decodePassLimitReached) { 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); }