Files
openclaw/src/gateway/security-path.ts
Peter Steinberger 26312fd0b0 refactor: remove gateway and channel dead exports (#106038)
* refactor: remove gateway and channel dead exports

* style: format config reload test

* refactor: remove newly dead gateway exports

* test: preserve approval audience coverage

* refactor: preserve gateway contracts while pruning exports

* test: retain gateway regression coverage

* test: restore gateway policy coverage

* test: close dead-export CI gaps

* fix: reconcile dead exports with latest main

* refactor: remove newly dead gateway runner export

* test: remove private worker verifier coverage

* test: preserve weak secret docs coverage

* fix: avoid gateway health import cycle

* fix: preserve node pairing type contract

* style: format talk relay test

* fix: preserve gateway protocol generation contract

* chore: refresh dead export baseline

* fix: preserve loaded target compatibility exports

* fix: preserve plugin SDK declaration resolution

* chore: refresh dead export baseline

* chore: refresh dead export baseline

* test(gateway): derive private worker service options
2026-07-13 05:47:05 -07:00

114 lines
3.1 KiB
TypeScript

// Gateway path security canonicalizes repeatedly encoded paths and protects
// plugin HTTP routes even under malformed encoding.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
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 resolveDotSegments(pathname: string): string {
try {
return new URL(pathname, "http://localhost").pathname;
} catch {
return pathname;
}
}
function normalizePathForSecurity(pathname: string): string {
return (
normalizePathSeparators(normalizeLowercaseStringOrEmpty(resolveDotSegments(pathname))) || "/"
);
}
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);
}
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;
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] ?? "/";
}
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(normalizeLowercaseStringOrEmpty(pathname)) || "/",
};
}
export const PROTECTED_PLUGIN_ROUTE_PREFIXES = ["/api/channels"] as const;