openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
329 lines (328 loc) • 14.2 kB
JavaScript
import { o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js";
//#region packages/net-policy/src/redact-sensitive-url.ts
/** Config UI hint tag for URL-like values that may embed credentials or tokens. */
const SENSITIVE_URL_HINT_TAG = "url-secret";
const SENSITIVE_URL_QUERY_PARAM_NAMES = /* @__PURE__ */ new Set([
"token",
"key",
"api_key",
"apikey",
"secret",
"access_token",
"auth_token",
"password",
"pass",
"passwd",
"auth",
"jwt",
"session",
"id_token",
"code",
"client_secret",
"app_secret",
"hook_token",
"refresh_token",
"signature",
"x_amz_signature",
"x_amz_security_token",
"private_key",
"credential",
"authorization",
"sig",
"x_api_key",
"x_access_token",
"x_auth_token"
]);
const URL_QUERY_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
const SUFFIXED_OR_SCOPED_TOKEN_QUERY_PARAM_RE = /(?:^|_)token(?:_[a-f0-9]{16,})?$/u;
const TELEGRAM_BOT_TOKEN_PATH_RE = /\/bot\d{6,}(?::|%3[aA])[A-Za-z0-9_-]{20,}(?=\/|$)/giu;
const MAX_NESTED_URL_REDACTION_DEPTH = 8;
const URL_SCHEME_RE = /(?:^|[^a-z\d+.-])[a-z][a-z\d+.-]{0,31}:/iu;
const SPECIAL_SCHEME_AUTHORITY_RE = /\b(?:https?|wss?|ftp):[\\/]{0,2}[^\\/?#\s]*/giu;
const SPECIAL_SCHEME_SPILLED_USERINFO_RE = /\b(?:https?|wss?|ftp):[\\/]{0,2}[^\s]*@[^\\/?#\s]*/giu;
const PROTOCOL_RELATIVE_AUTHORITY_RE = /[\\/]{2,}[^\\/?#\s]*/gu;
function redactSensitiveUrlPath(value) {
return value.replace(TELEGRAM_BOT_TOKEN_PATH_RE, "/bot***");
}
function normalizeUrlQueryParamName(name) {
let current = name.replace(URL_QUERY_NAME_SEPARATOR_RE, "");
for (let depth = 0; depth <= MAX_NESTED_URL_REDACTION_DEPTH; depth += 1) {
let decoded;
try {
decoded = decodeURIComponent(current).replace(URL_QUERY_NAME_SEPARATOR_RE, "");
} catch {
return {
value: normalizeLowercaseStringOrEmpty(current).replaceAll("-", "_"),
unresolvedEncoding: current.includes("%")
};
}
if (decoded === current) return {
value: normalizeLowercaseStringOrEmpty(current).replaceAll("-", "_"),
unresolvedEncoding: false
};
current = decoded;
}
return {
value: normalizeLowercaseStringOrEmpty(current).replaceAll("-", "_"),
unresolvedEncoding: current.includes("%")
};
}
function looksLikeNestedUrlValue(value) {
if (URL_SCHEME_RE.test(value)) return true;
const forwardAuthorityIndex = value.indexOf("//");
const backwardAuthorityIndex = value.indexOf("\\\\");
const authorityIndex = forwardAuthorityIndex < 0 ? backwardAuthorityIndex : backwardAuthorityIndex < 0 ? forwardAuthorityIndex : Math.min(forwardAuthorityIndex, backwardAuthorityIndex);
if (authorityIndex >= 0 && value.includes("@", authorityIndex + 2)) return true;
const queryIndex = value.search(/[?&]/u);
if (queryIndex >= 0 && value.includes("=", queryIndex + 1)) return true;
const fragmentIndex = value.indexOf("#");
if (fragmentIndex >= 0 && value.includes("=", fragmentIndex + 1)) return true;
return /%[\da-f]{2}/iu.test(value);
}
/** True for auth-like URL query parameter names that should be redacted. */
function isSensitiveUrlQueryParamName(name) {
const normalized = normalizeUrlQueryParamName(name);
return normalized.unresolvedEncoding || SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized.value) || SUFFIXED_OR_SCOPED_TOKEN_QUERY_PARAM_RE.test(normalized.value);
}
/** True for config paths whose URL values may contain credentials or secret query params. */
function isSensitiveUrlConfigPath(path) {
if (path.endsWith(".baseUrl") || path.endsWith(".httpUrl")) return true;
if (path.endsWith(".cdpUrl")) return true;
if (path.endsWith(".request.proxy.url")) return true;
return /^(?:nodeHost\.)?mcp\.servers\.(?:\*|[^.]+)\.url$/.test(path);
}
/** True when a config UI hint explicitly marks a URL-like value as secret-bearing. */
function hasSensitiveUrlHintTag(hint) {
return hint?.tags?.includes(SENSITIVE_URL_HINT_TAG) === true;
}
function redactDirectSensitiveUrl(value) {
try {
const parsed = new URL(value);
let mutated = false;
const redactedPath = redactSensitiveUrlPath(parsed.pathname);
if (redactedPath !== parsed.pathname) {
parsed.pathname = redactedPath;
mutated = true;
}
if (parsed.username || parsed.password) {
parsed.username = parsed.username ? "***" : "";
parsed.password = parsed.password ? "***" : "";
mutated = true;
}
for (const key of Array.from(parsed.searchParams.keys())) if (isSensitiveUrlQueryParamName(key)) {
parsed.searchParams.set(key, "***");
mutated = true;
}
return mutated ? parsed.toString() : value;
} catch {
return value;
}
}
function redactQueryString(value, depth) {
const params = new URLSearchParams(value);
const entries = Array.from(params.entries());
const redactedEntries = [];
const seenSensitiveKeys = /* @__PURE__ */ new Set();
let mutated = false;
for (const [key, entryValue] of entries) {
if (isSensitiveUrlQueryParamName(key)) {
mutated = true;
if (!seenSensitiveKeys.has(key)) {
seenSensitiveKeys.add(key);
redactedEntries.push([key, "***"]);
}
continue;
}
const redactedKey = redactNestedUrlValue(key, depth + 1);
const redactedValue = redactNestedUrlValue(entryValue, depth + 1);
if (redactedKey !== key || redactedValue !== entryValue) mutated = true;
redactedEntries.push([redactedKey, redactedValue]);
}
if (!mutated) return value;
const redactedParams = new URLSearchParams();
for (const [key, entryValue] of redactedEntries) redactedParams.append(key, entryValue);
return redactedParams.toString();
}
function redactUrlLikeFallback(value) {
return redactSensitiveUrlPath(redactEmbeddedUrlUserInfo(value).replace(/([?&])([^=&]+)=([^&]*)/g, (match, prefix, key) => isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match));
}
function redactAuthorityUserInfo(candidate, authorityStart) {
const authority = candidate.slice(authorityStart);
const userInfoEnd = authority.lastIndexOf("@");
if (userInfoEnd < 0) return candidate;
return `${candidate.slice(0, authorityStart)}***:***@${authority.slice(userInfoEnd + 1)}`;
}
function redactEmbeddedUrlUserInfo(value) {
return value.replace(SPECIAL_SCHEME_AUTHORITY_RE, (candidate) => {
let authorityStart = candidate.indexOf(":") + 1;
while (authorityStart < candidate.length && (candidate[authorityStart] === "/" || candidate[authorityStart] === "\\")) authorityStart += 1;
return redactAuthorityUserInfo(candidate, authorityStart);
}).replace(SPECIAL_SCHEME_SPILLED_USERINFO_RE, (candidate) => {
let authorityStart = candidate.indexOf(":") + 1;
while (authorityStart < candidate.length && (candidate[authorityStart] === "/" || candidate[authorityStart] === "\\")) authorityStart += 1;
const userInfoEnd = candidate.lastIndexOf("@");
const firstReservedDelimiter = candidate.slice(authorityStart).search(/[\\/?#]/u);
if (userInfoEnd < 0 || firstReservedDelimiter < 0) return candidate;
const absoluteReservedDelimiter = authorityStart + firstReservedDelimiter;
if (absoluteReservedDelimiter >= userInfoEnd) return candidate;
const credentialSeparator = candidate.indexOf(":", authorityStart);
if (credentialSeparator < 0 || credentialSeparator > absoluteReservedDelimiter) return candidate;
const authorityPrefix = candidate.slice(authorityStart, absoluteReservedDelimiter);
const possiblePort = candidate.slice(credentialSeparator + 1, absoluteReservedDelimiter);
if (/^\d+$/u.test(possiblePort) || /^\[[^\]]+\](?::\d+)?$/u.test(authorityPrefix)) return candidate;
return `${candidate.slice(0, authorityStart)}***:***@${candidate.slice(userInfoEnd + 1)}`;
}).replace(PROTOCOL_RELATIVE_AUTHORITY_RE, (candidate) => {
let authorityStart = 0;
while (authorityStart < candidate.length && (candidate[authorityStart] === "/" || candidate[authorityStart] === "\\")) authorityStart += 1;
return redactAuthorityUserInfo(candidate, authorityStart);
});
}
function hasUnresolvedEmbeddedUrlUserInfo(value) {
for (const match of value.matchAll(/(?:\b(?:https?|wss?|ftp):[\\/]{0,2}|[\\/]{2,})/giu)) {
const remainder = value.slice((match.index ?? 0) + match[0].length);
const userInfoEnd = remainder.search(/(?<!\*\*\*:\*\*\*)@/u);
const authorityEnd = remainder.search(/[\\/?#]/u);
const pathBeforeAt = remainder.slice(authorityEnd + 1, userInfoEnd);
if (userInfoEnd >= 0 && (authorityEnd < 0 || userInfoEnd <= authorityEnd || remainder[authorityEnd] === "/" && (pathBeforeAt.includes(":") || /^[^/?#\s]+\.[^/?#\s]+(?:[/?#]|$)/u.test(remainder.slice(userInfoEnd + 1))))) return true;
}
return false;
}
function redactRelativeUrlFragment(value, depth) {
const fragmentIndex = value.indexOf("#");
if (fragmentIndex < 0) return value;
const fragment = value.slice(fragmentIndex + 1);
const redactedFragment = redactFragment(fragment, depth + 1);
return redactedFragment === fragment ? value : `${value.slice(0, fragmentIndex + 1)}${redactedFragment}`;
}
function redactFragment(value, depth) {
if (!value) return value;
if (depth > MAX_NESTED_URL_REDACTION_DEPTH && looksLikeNestedUrlValue(value)) return "***";
const wholeUrl = redactSensitiveUrlAtDepth(value, depth);
if (wholeUrl.parsedWholeUrl) return redactUrlLikeFallback(wholeUrl.value);
const candidate = value;
const firstQueryDelimiter = candidate.search(/[?&]/u);
const firstEquals = candidate.indexOf("=");
if (firstEquals >= 0 && (firstQueryDelimiter < 0 || firstEquals < firstQueryDelimiter)) return redactQueryString(candidate, depth);
const hashRouterQueryIndex = candidate.indexOf("?");
if (hashRouterQueryIndex >= 0) {
const redactedQuery = redactQueryString(candidate.slice(hashRouterQueryIndex + 1), depth);
return `${redactEncodedUrlLikeString(redactUrlLikeFallback(candidate.slice(0, hashRouterQueryIndex + 1)), depth + 1)}${redactedQuery}`;
}
const fallback = redactUrlLikeFallback(candidate);
if (!looksLikeNestedUrlValue(fallback)) return fallback;
let decoded;
try {
decoded = decodeURIComponent(fallback);
} catch {
return "***";
}
if (decoded === fallback) return fallback;
const redactedDecoded = redactFragment(decoded, depth + 1);
return redactedDecoded === decoded ? fallback : encodeURIComponent(redactedDecoded);
}
function redactEncodedNestedUrlPath(value, depth) {
if (!looksLikeNestedUrlValue(value)) return value;
if (depth > MAX_NESTED_URL_REDACTION_DEPTH) return "***";
let decoded;
try {
decoded = decodeURIComponent(value);
} catch {
return "***";
}
if (decoded === value) return value;
const direct = redactSensitiveUrlLikeStringAtDepth(decoded, depth);
if (direct.value !== decoded || hasUnresolvedEmbeddedUrlUserInfo(decoded)) return direct.value !== decoded ? direct.value : "***";
if (direct.parsedWholeUrl) return value;
const nested = redactEncodedNestedUrlPath(decoded, depth + 1);
return nested === decoded ? value : nested;
}
function redactSensitiveUrlAtDepth(value, depth) {
try {
const directRedaction = redactDirectSensitiveUrl(value);
const parsed = new URL(directRedaction);
if (depth > MAX_NESTED_URL_REDACTION_DEPTH) return {
value: "***",
parsedWholeUrl: true
};
let mutated = directRedaction !== value;
const redactedNestedPath = redactEmbeddedUrlUserInfo(redactEncodedNestedUrlPath(parsed.pathname, depth + 1));
if (redactedNestedPath !== parsed.pathname) {
const originalPath = parsed.pathname;
parsed.pathname = redactedNestedPath;
if (parsed.pathname === originalPath) return {
value: directRedaction,
parsedWholeUrl: false
};
mutated = true;
}
const redactedQuery = redactQueryString(parsed.search.slice(1), depth);
if (redactedQuery !== parsed.search.slice(1)) {
parsed.search = redactedQuery;
mutated = true;
}
const fragment = parsed.hash.slice(1);
const redactedHash = redactFragment(fragment, depth + 1);
if (redactedHash !== fragment) {
parsed.hash = redactedHash;
mutated = true;
}
return {
value: mutated ? parsed.toString() : value,
parsedWholeUrl: true
};
} catch {
return {
value,
parsedWholeUrl: false
};
}
}
function redactSensitiveUrlLikeStringAtDepth(value, depth) {
const redactedUrl = redactSensitiveUrlAtDepth(value, depth);
if (redactedUrl.parsedWholeUrl) return redactedUrl;
return {
value: redactEncodedUrlLikeString(redactRelativeUrlFragment(redactUrlLikeFallback(redactedUrl.value), depth), depth + 1),
parsedWholeUrl: false
};
}
function redactEncodedUrlLikeString(value, depth) {
if (!looksLikeNestedUrlValue(value)) return value;
if (depth > MAX_NESTED_URL_REDACTION_DEPTH) return "***";
let decoded;
try {
decoded = decodeURIComponent(value);
} catch {
return "***";
}
if (decoded === value) return value;
const redactedDecoded = redactSensitiveUrlLikeStringAtDepth(decoded, depth + 1);
if (redactedDecoded.value !== decoded || redactedDecoded.parsedWholeUrl) return redactedDecoded.value === decoded ? value : redactedDecoded.value;
return hasUnresolvedEmbeddedUrlUserInfo(decoded) ? "***" : value;
}
function redactNestedUrlValue(value, depth) {
if (!looksLikeNestedUrlValue(value)) return value;
if (depth > MAX_NESTED_URL_REDACTION_DEPTH) return "***";
const direct = redactSensitiveUrlLikeStringAtDepth(value, depth);
if (direct.value !== value) return direct.value;
if (hasUnresolvedEmbeddedUrlUserInfo(value)) return "***";
if (direct.parsedWholeUrl) return value;
let decoded;
try {
decoded = decodeURIComponent(value);
} catch {
return "***";
}
if (decoded === value || !looksLikeNestedUrlValue(decoded)) return value;
const redactedDecoded = redactNestedUrlValue(decoded, depth + 1);
return redactedDecoded === decoded ? value : encodeURIComponent(redactedDecoded);
}
/** Redacts credentials and sensitive query params from URL values. */
function redactSensitiveUrl(value) {
return redactSensitiveUrlLikeStringAtDepth(value, 0).value;
}
/** Redacts sensitive URL-looking substrings even when the full value is not a valid URL. */
function redactSensitiveUrlLikeString(value) {
return redactSensitiveUrlLikeStringAtDepth(value, 0).value;
}
//#endregion
export { redactSensitiveUrl as a, isSensitiveUrlQueryParamName as i, hasSensitiveUrlHintTag as n, redactSensitiveUrlLikeString as o, isSensitiveUrlConfigPath as r, SENSITIVE_URL_HINT_TAG as t };