mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 11:30:41 +00:00
162 lines
4.6 KiB
TypeScript
162 lines
4.6 KiB
TypeScript
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<string>, 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<string>();
|
|
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<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 = 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);
|
|
}
|