mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
534 lines
17 KiB
TypeScript
534 lines
17 KiB
TypeScript
import { redactErrorPaths, stripErrorStackTail } from "./errorPathRedaction.ts";
|
|
|
|
// Length cap protects against pathological inputs even before tokenization.
|
|
const MAX_ERROR_LEN = 4096;
|
|
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 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): 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 (
|
|
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 === "/" || (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, MAX_ERROR_LEN) : 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 === "/" || escaped === '"' || escaped === "'") return true;
|
|
if (escaped === "u" || escaped === "U") {
|
|
const decoded = unicodeEscapeCodeAt(value, index - 1);
|
|
if (isPrintableAscii(decoded)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */
|
|
function normalizeSecurityEscapes(value: string, decodeQuotes: boolean): string {
|
|
let normalized = value.slice(0, MAX_ERROR_LEN);
|
|
for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) {
|
|
const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes);
|
|
if (decoded === normalized) break;
|
|
normalized = decoded.slice(0, MAX_ERROR_LEN);
|
|
}
|
|
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);
|
|
|
|
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++;
|
|
}
|
|
while (/\s/.test(value[index])) index++;
|
|
if (value[index] !== ":" && value[index] !== "=") continue;
|
|
index++;
|
|
while (/\s/.test(value[index])) index++;
|
|
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$/.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("");
|
|
}
|
|
|
|
export function redactSensitiveErrorText(value: string): string {
|
|
const commonCredentialsRedacted = redactBase64DataUrls(redactPrivateKeyPemBlocks(value))
|
|
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
|
|
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
|
|
return redactLabeledCredentialAssignments(commonCredentialsRedacted);
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip stack-trace tails, credentials, and absolute source paths from a
|
|
* client-visible error message.
|
|
*/
|
|
export function sanitizeErrorMessage(message: unknown): string {
|
|
let str = coerceErrorText(message);
|
|
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
|
|
// Preserve quote provenance until hidden labels/delimiters have been
|
|
// exposed and redacted, then decode safe quote escapes in the clean text.
|
|
str = redactSensitiveErrorText(str);
|
|
str = normalizeSecurityEscapes(str, false);
|
|
str = redactSensitiveErrorText(redactErrorPaths(stripErrorStackTail(str)));
|
|
str = normalizeSecurityEscapes(str, true);
|
|
str = redactSensitiveErrorText(redactErrorPaths(stripErrorStackTail(str)));
|
|
return hasResidualSecurityEscape(str) ? "[REDACTED]" : str;
|
|
}
|
|
|
|
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;
|
|
|
|
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.
|
|
*/
|
|
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
|
|
if (depth > MAX_DEPTH) return "[truncated]";
|
|
if (value === null || value === undefined) return null;
|
|
if (typeof value === "string") return sanitizeErrorMessage(value);
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
|
|
}
|
|
if (typeof value === "object") {
|
|
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;
|
|
out[key] = sanitizeUpstreamDetails(entryValue, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
return null;
|
|
}
|