Files
OmniRoute/open-sse/utils/errorSanitization.ts
Diego Rodrigues de Sa e Souza 2265ce761f fix(security): harden public error boundaries (#12506)
Validado sobre o tip de `release/v3.8.51` depois de reconciliar com o #12620, que entrou primeiro nesta mesma sessão e ataca a mesma classe de problema por outra arquitetura.

**A colisão e como foi resolvida.** O #12620 consertou o GHSA-qv45-56jc-4wmj adicionando `RAW_CREDENTIAL_PATTERNS` a `error.ts` e importando-os em `upstreamErrorPassthrough.ts`. Este PR resolve o mesmo problema quebrando `error.ts` em `errorSanitization.ts` + `errorPathRedaction.ts`. Mantive a divisão em módulos deste PR, porque ao comparar os dois vocabulários o dele já era mais amplo: o `STRONG_CREDENTIAL_TOKEN` daqui cobre `sk-`/`sk_` **com lookbehind e uma variante para a forma embutida** (que pega `sk-proj-…`), mais Slack `xox-`, AWS `AKIA`/`ASIA`, `github_pat_`/`ghp_`/`glpat-` e JWT de três segmentos.

A única forma que o #12620 carregava e este conjunto não tinha era a chave do Google (`AIza…`) — adicionada aqui, com o mesmo quantificador limitado que os irmãos usam (AGENTS.md → PII §1, já que isso roda sobre corpos upstream não confiáveis).

**A verificação não foi por inspeção.** Rodei as suítes do próprio #12620 contra esta estrutura: **48/48** em `error-sanitizer-sk-key-qv45`, `bifrost-relay-response-leak-9m72`, `search-baseurl-client-override-3f8g` e `search-baseurl-ssrf-guard` — incluindo a asserção anti-drift daquela suíte, que é o oráculo certo aqui: *para todo corpo que a camada de passthrough recusa como vazante, o sanitizador de fallback não pode devolvê-lo intacto*. Ela passa, então a propriedade de segurança dos três GHSAs sobrevive à troca de arquitetura.

Os 21 arquivos de teste deste PR: **259/259**. `typecheck:core` limpo.
2026-09-03 21:31:13 -03:00

896 lines
28 KiB
TypeScript

import {
redactErrorPaths,
stripErrorStackTail,
stripRecognizedErrorStackTail,
} from "./errorPathRedaction.ts";
import { CREDENTIAL_PATTERNS } from "./credentialPatterns.ts";
// Length cap protects against pathological inputs even before tokenization.
const MAX_ERROR_LEN = 4096;
const MAX_ERROR_SCAN_HEADROOM = 512;
const MAX_SECURITY_ESCAPE_LAYERS = 3;
const STRONG_CREDENTIAL_TOKEN_SOURCE =
"(?:eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|" +
"github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|" +
"xox[a-z]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|" +
"(?<![A-Za-z0-9])sk[-_][A-Za-z0-9._~+/=-]{8,}|" +
"[A-Za-z0-9]{3,}sk[-_][A-Za-z0-9._~+/=-]{8,})";
const STRONG_CREDENTIAL_TOKEN = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "i");
const STRONG_CREDENTIAL_TOKEN_GLOBAL = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "gi");
export function containsStrongCredentialToken(value: string): boolean {
return STRONG_CREDENTIAL_TOKEN.test(value);
}
const CREDENTIAL_LABELS = [
["__secure-next-auth.session-token", true],
["arena-auth-prod-v1", true],
["__cf_bm", true],
["_cfuvid", true],
["_puid", true],
["access_token_v2", true],
["token_v2", true],
["tokenv2", true],
["cf_clearance", true],
["credentials", true],
["credential", true],
["session id", true],
["session-id", true],
["session_id", true],
["sessionid", true],
["encryption key", true],
["encryption-key", true],
["encryption_key", true],
["encryptionkey", true],
["private key", true],
["private-key", true],
["private_key", true],
["privatekey", true],
["session key", true],
["session-key", true],
["session_key", true],
["sessionkey", true],
["secret key", true],
["secret-key", true],
["secret_key", true],
["secretkey", true],
["signing key", true],
["signing-key", true],
["signing_key", true],
["signingkey", true],
["refresh token", false],
["refresh-token", false],
["refresh_token", false],
["refreshtoken", false],
["access token", false],
["access-token", false],
["access_token", false],
["accesstoken", false],
["authorization", true],
["sso-rw", true],
["session", true],
["sso", true],
["api key", false],
["api-key", false],
["api_key", false],
["apikey", false],
["password", true],
["cookie", true],
["secret", true],
["token", false],
] as const;
type CredentialAssignment = {
valueStart: number;
failClosed: boolean;
};
function isAsciiAlphaNumericCode(code: number): boolean {
return (
(code >= 0x30 && code <= 0x39) ||
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a)
);
}
function asciiHexValue(code: number): number {
if (code >= 0x30 && code <= 0x39) return code - 0x30;
if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10;
if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10;
return -1;
}
function unicodeEscapeCodeAt(value: string, start: number): number | null {
if (
value.charCodeAt(start) !== 0x5c ||
(value[start + 1] !== "u" && value[start + 1] !== "U") ||
start + 5 >= value.length
) {
return null;
}
let decoded = 0;
for (let digit = start + 2; digit <= start + 5; digit++) {
const nibble = asciiHexValue(value.charCodeAt(digit));
if (nibble < 0) return null;
decoded = decoded * 16 + nibble;
}
return decoded;
}
function isPrintableAscii(code: number | null): code is number {
return code !== null && code >= 0x20 && code <= 0x7e;
}
function isSecurityWhitespaceCode(code: number | null): boolean {
return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
}
function isEscapeTokenBoundary(code: number): boolean {
return !isAsciiAlphaNumericCode(code) && code !== 0x2e && code !== 0x5f && code !== 0x2d;
}
function shouldPreserveUnicodeUncEvidence(
value: string,
runStart: number,
runEnd: number,
decoded: number
): boolean {
if (
runEnd - runStart < 2 ||
decoded === 0x2f ||
decoded === 0x5c ||
decoded === 0x3a ||
(runStart > 0 && !isEscapeTokenBoundary(value.charCodeAt(runStart - 1)))
) {
return false;
}
const afterEscape = runEnd + 5;
let tokenEnd = afterEscape;
while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
if (value.slice(afterEscape, tokenEnd).includes("=")) return false;
return afterEscape < tokenEnd;
}
function decodeSecurityEscapesOnce(
value: string,
decodeQuotes: boolean,
maxLength: number
): string {
const output: string[] = [];
let changed = false;
for (let index = 0; index < value.length; index++) {
if (value.charCodeAt(index) !== 0x5c) {
output.push(value[index]);
continue;
}
const runStart = index;
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
const runEnd = index;
if (runEnd >= value.length) {
output.push(value.slice(runStart));
break;
}
const escaped = value[runEnd];
if (escaped === "u" || escaped === "U") {
const decoded = unicodeEscapeCodeAt(value, runEnd - 1);
const isQuote = decoded === 0x22 || decoded === 0x27;
if (isSecurityWhitespaceCode(decoded)) {
output.push(" ");
index = runEnd + 4;
changed = true;
continue;
}
if (
isPrintableAscii(decoded) &&
(decodeQuotes || !isQuote) &&
!shouldPreserveUnicodeUncEvidence(value, runStart, runEnd, decoded)
) {
output.push(String.fromCharCode(decoded));
index = runEnd + 4;
changed = true;
continue;
}
output.push(value.slice(runStart, runEnd + 5));
index = runEnd + 4;
continue;
}
if (
escaped === "b" ||
escaped === "f" ||
escaped === "n" ||
escaped === "r" ||
escaped === "t"
) {
output.push(" ");
index = runEnd;
changed = true;
continue;
}
if (escaped === "/" || (decodeQuotes && (escaped === '"' || escaped === "'"))) {
output.push(escaped);
index = runEnd;
changed = true;
continue;
}
output.push(value.slice(runStart, runEnd));
index = runEnd - 1;
}
return changed ? output.join("").slice(0, maxLength) : value;
}
function hasResidualSecurityEscape(value: string): boolean {
for (let index = 0; index < value.length; index++) {
if (value.charCodeAt(index) !== 0x5c) continue;
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
if (index >= value.length) return false;
const escaped = value[index];
if (
escaped === "b" ||
escaped === "f" ||
escaped === "n" ||
escaped === "r" ||
escaped === "t"
) {
return true;
}
if (escaped === "/" || escaped === '"' || escaped === "'") return true;
if (escaped === "u" || escaped === "U") {
const decoded = unicodeEscapeCodeAt(value, index - 1);
if (isPrintableAscii(decoded) || isSecurityWhitespaceCode(decoded)) return true;
}
}
return false;
}
/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */
function normalizeSecurityEscapes(
value: string,
decodeQuotes: boolean,
maxLength = MAX_ERROR_LEN
): string {
let normalized = value.slice(0, maxLength);
for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) {
const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes, maxLength);
if (decoded === normalized) break;
normalized = decoded.slice(0, maxLength);
}
return normalized;
}
function isCredentialLabelBoundary(code: number): boolean {
return !isAsciiAlphaNumericCode(code) && code !== 0x5f && code !== 0x2d;
}
function matchCredentialAssignmentAt(value: string, start: number): CredentialAssignment | null {
const keyQuote = value[start] === '"' || value[start] === "'" ? value[start] : "";
const labelStart = start + (keyQuote ? 1 : 0);
const cliFlag =
!keyQuote &&
labelStart >= 2 &&
value.slice(labelStart - 2, labelStart) === "--" &&
(labelStart === 2 || isCredentialLabelBoundary(value.charCodeAt(labelStart - 3)));
for (const [label, failClosed] of CREDENTIAL_LABELS) {
const labelEnd = labelStart + label.length;
if (value.slice(labelStart, labelEnd).toLowerCase() !== label) continue;
let index = labelEnd;
if (
(label === "arena-auth-prod-v1" || label === "__secure-next-auth.session-token") &&
value[index] === "."
) {
const chunkStart = ++index;
while (index < value.length && /\d/.test(value[index])) index++;
if (index === chunkStart) continue;
}
if (keyQuote) {
if (value[index] !== keyQuote) continue;
index++;
} else if (!isCredentialLabelBoundary(value.charCodeAt(index))) {
continue;
} else if (value[index] === '"' || value[index] === "'") {
index++;
}
const separatorStart = index;
while (/\s/.test(value[index])) index++;
if (value[index] === ":" || value[index] === "=") {
index++;
while (/\s/.test(value[index])) index++;
} else if (!(cliFlag && index > separatorStart)) {
continue;
}
return { valueStart: index, failClosed };
}
return null;
}
function findQuotedCredentialEnd(value: string, start: number, quote: string): number {
let index = start + 1;
while (index < value.length) {
if (value.charCodeAt(index) === 0x5c) {
index += 2;
continue;
}
if (value[index] === quote) return index;
index++;
}
return -1;
}
function findUnquotedCredentialEnd(value: string, start: number): number {
let end = start;
while (end < value.length) {
const char = value[end];
if (/\s/.test(char) || char === '"' || char === "'" || char === "," || char === "}") break;
end++;
}
return end;
}
function redactLabeledCredentialAssignments(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const assignment = matchCredentialAssignmentAt(value, index);
if (!assignment) {
index++;
continue;
}
const { valueStart, failClosed } = assignment;
const quote = value[valueStart] === '"' || value[valueStart] === "'" ? value[valueStart] : "";
if (quote) {
const closingQuote = findQuotedCredentialEnd(value, valueStart, quote);
parts.push(value.slice(copyStart, valueStart + 1), "[REDACTED]");
if (closingQuote < 0) {
copyStart = value.length;
index = value.length;
} else {
parts.push(quote);
copyStart = closingQuote + 1;
index = copyStart;
}
continue;
}
// A leading backslash may be a serialized quote or another encoded
// delimiter. Do not redact only that prefix and leave the value behind.
const valueEnd =
failClosed || value.charCodeAt(valueStart) === 0x5c
? value.length
: findUnquotedCredentialEnd(value, valueStart);
parts.push(value.slice(copyStart, valueStart), "[REDACTED]");
copyStart = valueEnd;
index = Math.max(valueEnd, valueStart + 1);
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function redactPrivateKeyPemBlocks(value: string): string {
// ASCII-only fold keeps offsets aligned even when the surrounding message
// contains Unicode characters whose full uppercase form expands in length.
const upperValue = value.replace(/[a-z]/g, (char) => char.toUpperCase());
const beginPrefix = "-----BEGIN ";
const parts: string[] = [];
let copyStart = 0;
let searchStart = 0;
while (searchStart < value.length) {
const blockStart = upperValue.indexOf(beginPrefix, searchStart);
if (blockStart < 0) break;
const labelStart = blockStart + beginPrefix.length;
const headerEnd = upperValue.indexOf("-----", labelStart);
if (headerEnd < 0) break;
const label = upperValue.slice(labelStart, headerEnd).trim();
if (!/^(?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?$/.test(label)) {
searchStart = headerEnd + 5;
continue;
}
const endMarker = `-----END ${label}-----`;
const closingStart = upperValue.indexOf(endMarker, headerEnd + 5);
const blockEnd = closingStart < 0 ? value.length : closingStart + endMarker.length;
parts.push(value.slice(copyStart, blockStart), "[REDACTED]");
copyStart = blockEnd;
searchStart = blockEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
const DATA_URL_PREFIX = "data:";
const BASE64_DATA_URL_MARKER = ";base64";
const REDACTED_DATA_URL = "[REDACTED_DATA_URL]";
function matchesAsciiCaseInsensitiveAt(value: string, start: number, expected: string): boolean {
if (start < 0 || start + expected.length > value.length) return false;
for (let offset = 0; offset < expected.length; offset++) {
const code = value.charCodeAt(start + offset);
const foldedCode = code >= 0x41 && code <= 0x5a ? code + 0x20 : code;
if (foldedCode !== expected.charCodeAt(offset)) return false;
}
return true;
}
function isBase64DataUrlPayloadCode(code: number): boolean {
return (
isAsciiAlphaNumericCode(code) ||
code === 0x2b ||
code === 0x2f ||
code === 0x3d ||
code === 0x5f ||
code === 0x2d
);
}
function isEcmaScriptWhitespaceCode(code: number): boolean {
return (
(code >= 0x09 && code <= 0x0d) ||
code === 0x20 ||
code === 0xa0 ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) ||
code === 0x2028 ||
code === 0x2029 ||
code === 0x202f ||
code === 0x205f ||
code === 0x3000 ||
code === 0xfeff
);
}
/** Redact base64 data URLs in one pass, including input with many repeated `data:` prefixes. */
function redactBase64DataUrls(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
if (!matchesAsciiCaseInsensitiveAt(value, index, DATA_URL_PREFIX)) {
index++;
continue;
}
const dataUrlStart = index;
const mediaTypeStart = dataUrlStart + DATA_URL_PREFIX.length;
let delimiter = mediaTypeStart;
while (
delimiter < value.length &&
value[delimiter] !== "," &&
!isEcmaScriptWhitespaceCode(value.charCodeAt(delimiter))
) {
delimiter++;
}
const markerStart = delimiter - BASE64_DATA_URL_MARKER.length;
const hasBase64Marker =
delimiter < value.length &&
value[delimiter] === "," &&
markerStart >= mediaTypeStart &&
matchesAsciiCaseInsensitiveAt(value, markerStart, BASE64_DATA_URL_MARKER);
if (!hasBase64Marker) {
index = delimiter < value.length ? delimiter + 1 : value.length;
continue;
}
let payloadEnd = delimiter + 1;
while (payloadEnd < value.length && isBase64DataUrlPayloadCode(value.charCodeAt(payloadEnd))) {
payloadEnd++;
}
if (payloadEnd === delimiter + 1) {
index = delimiter + 1;
continue;
}
parts.push(value.slice(copyStart, dataUrlStart), REDACTED_DATA_URL);
copyStart = payloadEnd;
index = payloadEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
const HTTP_URL_RE = /https?:\/\//gi;
const URL_QUERY_PARAM_RE = /([?&])([^=&#]+)=([^&#]*)/g;
function isUrlTerminator(char: string): boolean {
return (
/\s/.test(char) ||
char === '"' ||
char === "'" ||
char === "`" ||
char === "<" ||
char === ">" ||
char === ")" ||
char === "]" ||
char === "}" ||
char === "," ||
char === ";"
);
}
function normalizeUrlQueryKey(key: string): string {
let decoded = key.replace(/\+/g, " ");
try {
decoded = decodeURIComponent(decoded);
} catch {
// Malformed percent escapes stay visible to the conservative ASCII fold.
}
return decoded.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
}
function isSensitiveUrlQueryKey(key: string): boolean {
const normalized = normalizeUrlQueryKey(key);
return (
normalized === "sig" ||
normalized === "signature" ||
normalized === "key" ||
normalized === "apikey" ||
normalized === "token" ||
normalized === "accesstoken" ||
normalized === "refreshtoken" ||
normalized === "credential" ||
normalized === "password" ||
normalized === "secret" ||
normalized === "awsaccesskeyid" ||
normalized === "googleaccessid" ||
normalized === "xamzcredential" ||
normalized === "xamzsignature" ||
normalized === "xamzsecuritytoken" ||
normalized === "xgoogcredential" ||
normalized === "xgoogsignature"
);
}
function redactUrlSegment(segment: string): string {
const schemeEnd = segment.indexOf("//") + 2;
let authorityEnd = segment.length;
for (const delimiter of ["/", "?", "#"]) {
const candidate = segment.indexOf(delimiter, schemeEnd);
if (candidate >= 0) authorityEnd = Math.min(authorityEnd, candidate);
}
let redacted = segment;
const userInfoEnd = segment.lastIndexOf("@", authorityEnd);
if (userInfoEnd >= schemeEnd) {
redacted = `${segment.slice(0, schemeEnd)}[REDACTED]@${segment.slice(userInfoEnd + 1)}`;
}
URL_QUERY_PARAM_RE.lastIndex = 0;
return redacted.replace(URL_QUERY_PARAM_RE, (match, separator: string, key: string) =>
isSensitiveUrlQueryKey(key) ? `${separator}redacted=[REDACTED]` : match
);
}
function redactSensitiveUrlCredentials(value: string): string {
HTTP_URL_RE.lastIndex = 0;
const parts: string[] = [];
let copyStart = 0;
let match = HTTP_URL_RE.exec(value);
while (match) {
const start = match.index;
let end = HTTP_URL_RE.lastIndex;
while (end < value.length && !isUrlTerminator(value[end])) end++;
const segment = value.slice(start, end);
const redacted = redactUrlSegment(segment);
if (redacted !== segment) {
parts.push(value.slice(copyStart, start), redacted);
copyStart = end;
}
HTTP_URL_RE.lastIndex = Math.max(end, HTTP_URL_RE.lastIndex);
match = HTTP_URL_RE.exec(value);
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function redactKnownCredentialPatterns(value: string): string {
let redacted = value;
for (const pattern of CREDENTIAL_PATTERNS) {
if (pattern.name === "auth_header") continue;
pattern.regex.lastIndex = 0;
redacted = redacted.replace(pattern.regex, "[REDACTED]");
}
return redacted;
}
export function redactSensitiveErrorText(value: string): string {
const normalized = normalizeSecurityEscapes(
value,
false,
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
);
const catalogRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized));
const commonCredentialsRedacted = redactBase64DataUrls(redactPrivateKeyPemBlocks(catalogRedacted))
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
return redactLabeledCredentialAssignments(commonCredentialsRedacted);
}
export function containsSensitiveErrorCredential(value: string): boolean {
const normalized = normalizeSecurityEscapes(
value,
false,
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
);
const directRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized))
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
if (directRedacted !== normalized) return true;
if (
/(?:^|\s)--(?:api[-_]?key|token|password|secret)\s+(?:"[^"]*"|'[^']*'|\S+)/i.test(normalized)
) {
return true;
}
return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["']?[^"'\\,\s}]{6,}/i.test(
normalized
);
}
function coerceErrorText(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return String(value);
} catch {
// Fail closed when an attacker-controlled toString/valueOf accessor throws.
return "";
}
}
function truncateSanitizedErrorText(value: string): string {
if (value.length <= MAX_ERROR_LEN) return value;
const markerStart = value.lastIndexOf("[REDACTED", MAX_ERROR_LEN);
const markerEnd = markerStart >= 0 ? value.indexOf("]", markerStart) : -1;
if (
markerStart >= 0 &&
markerStart < MAX_ERROR_LEN &&
markerEnd >= MAX_ERROR_LEN &&
markerEnd - markerStart <= 128
) {
const marker = value.slice(markerStart, markerEnd + 1);
return `${value.slice(0, MAX_ERROR_LEN - marker.length)}${marker}`;
}
return value.slice(0, MAX_ERROR_LEN);
}
/**
* Strip stack-trace tails, credentials, and absolute source paths from a
* client-visible error message.
*/
function sanitizeErrorMessageWithStackPolicy(
message: unknown,
stripStackTail: (value: string) => string
): string {
let str = coerceErrorText(message);
if (str.length > MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM) {
str = str.slice(0, MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM);
}
// Preserve quote provenance until hidden labels/delimiters have been
// exposed and redacted, then decode safe quote escapes in the clean text.
// Raw URI credentials must be projected before the path tokenizer consumes
// the URI tail; Windows path evidence still stays intact until after this
// credential-only pass and is redacted before escape normalization.
str = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(stripStackTail(str)));
str = redactErrorPaths(str);
str = redactSensitiveErrorText(str);
str = truncateSanitizedErrorText(str);
str = normalizeSecurityEscapes(str, false);
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
str = normalizeSecurityEscapes(str, true);
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
return hasResidualSecurityEscape(str) ? "[REDACTED]" : str.trimEnd();
}
export function sanitizeErrorMessage(message: unknown): string {
return sanitizeErrorMessageWithStackPolicy(message, stripErrorStackTail);
}
function sanitizePassthroughErrorMessage(message: unknown): string {
return sanitizeErrorMessageWithStackPolicy(message, stripRecognizedErrorStackTail);
}
const BLOCKED_KEYS =
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie|credential|session(?!_?(?:count|status)$)/i;
const BLOCKED_CREDENTIAL_ALIAS_KEYS =
/^(?:cf_clearance|__cf_bm|_cfuvid|_puid|sso|sso-rw|arena-auth-prod-v1(?:\.\d+)?)$/i;
const PROTOTYPE_CONTROL_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 4;
const MAX_UPSTREAM_KEY_LEN = 256;
type UpstreamClassificationKey = "code" | "reason" | "status" | "type";
const SAFE_UPSTREAM_STATUS_IDENTIFIERS = new Set([
"ABORTED",
"ALREADY_EXISTS",
"CANCELLED",
"DATA_LOSS",
"DEADLINE_EXCEEDED",
"FAILED_PRECONDITION",
"INTERNAL",
"INVALID_ARGUMENT",
"NOT_FOUND",
"OK",
"OUT_OF_RANGE",
"PERMISSION_DENIED",
"RESOURCE_EXHAUSTED",
"UNAUTHENTICATED",
"UNAVAILABLE",
"UNIMPLEMENTED",
"UNKNOWN",
]);
const SAFE_UPSTREAM_ERROR_IDENTIFIERS = new Set([
"api_error",
"auth_error",
"authentication_error",
"bad_gateway",
"bad_request",
"billing_error",
"context_length_exceeded",
"error",
"gateway_timeout",
"insufficient_quota",
"invalid_api_key",
"invalid_request",
"invalid_request_error",
"model_not_found",
"not_found",
"payment_required",
"permission_error",
"provider_error",
"quota_exhausted",
"rate_limit_error",
"rate_limit_exceeded",
"server_error",
"upstream_error",
"upstream_timeout",
]);
function describeOpaqueBinaryDetail(value: ArrayBuffer | ArrayBufferView): string {
return `[binary ${value.byteLength} bytes]`;
}
function normalizeUpstreamClassificationKey(key: string): UpstreamClassificationKey | null {
const normalized = key.replace(/[-_]/g, "").toLowerCase();
if (normalized === "code" || normalized === "errorcode") return "code";
if (normalized === "reason" || normalized === "errorreason") return "reason";
if (
normalized === "status" ||
normalized === "statuscode" ||
normalized === "errorstatus" ||
normalized === "errorstatuscode"
) {
return "status";
}
if (normalized === "type" || normalized === "errortype" || normalized === "subtype") {
return "type";
}
return null;
}
function projectUpstreamErrorIdentifier(key: UpstreamClassificationKey, value: unknown): unknown {
if (typeof value === "number") {
if (!Number.isInteger(value)) return undefined;
if (key === "code" && value >= 0 && value <= 16) return value;
return (key === "code" || key === "status") && value >= 100 && value <= 599 ? value : undefined;
}
if (typeof value !== "string") return undefined;
if (key === "status" && SAFE_UPSTREAM_STATUS_IDENTIFIERS.has(value.toUpperCase())) {
return value;
}
if (
/^[1-5]\d{2}$/.test(value) ||
/^HTTP_[1-5]\d{2}$/i.test(value) ||
SAFE_UPSTREAM_ERROR_IDENTIFIERS.has(value.toLowerCase())
) {
return value;
}
if (key === "type") return "upstream_error";
if (key === "code") return "";
return undefined;
}
function isSafeUpstreamDetailKey(key: string): boolean {
if (
key.length === 0 ||
key.length > MAX_UPSTREAM_KEY_LEN ||
BLOCKED_KEYS.test(key) ||
BLOCKED_CREDENTIAL_ALIAS_KEYS.test(key) ||
PROTOTYPE_CONTROL_KEYS.has(key.toLowerCase())
) {
return false;
}
return sanitizeErrorMessage(key) === key;
}
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* Unsafe keys are dropped rather than renamed so sanitized-key collisions
* cannot restore a secret under a public placeholder.
*/
function sanitizeUpstreamDetailsInternal(
value: unknown,
depth: number,
preserveSafeMultiline: boolean,
projectClassification: boolean
): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") {
return preserveSafeMultiline
? sanitizePassthroughErrorMessage(value)
: sanitizeErrorMessage(value);
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "object") {
try {
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
return describeOpaqueBinaryDetail(value);
}
if (Array.isArray(value)) {
return value
.slice(0, 32)
.map((entry) =>
sanitizeUpstreamDetailsInternal(
entry,
depth + 1,
preserveSafeMultiline,
projectClassification
)
);
}
const out = Object.create(null) as Record<string, unknown>;
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
if (!isSafeUpstreamDetailKey(key)) continue;
const normalizedKey = key.toLowerCase();
const classificationKey = normalizeUpstreamClassificationKey(normalizedKey);
if (projectClassification && classificationKey) {
const projected = projectUpstreamErrorIdentifier(classificationKey, entryValue);
if (projected !== undefined) out[key] = projected;
continue;
}
const childProjectsClassification =
normalizedKey === "error" ||
normalizedKey === "errors" ||
normalizedKey === "warning" ||
normalizedKey === "warnings";
out[key] = sanitizeUpstreamDetailsInternal(
entryValue,
depth + 1,
preserveSafeMultiline,
childProjectsClassification
);
}
return out;
} catch {
return null;
}
}
return null;
}
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
return sanitizeUpstreamDetailsInternal(value, depth, false, depth === 0);
}
/** Provider-only projection that preserves safe multiline capability wording. */
export function sanitizePassthroughUpstreamDetails(value: unknown, depth = 0): unknown {
return sanitizeUpstreamDetailsInternal(value, depth, true, depth === 0);
}