Files
OmniRoute/open-sse/utils/errorPathRedaction.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

906 lines
32 KiB
TypeScript

const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"] as const;
const NATIVE_EXT = ["node", "so", "dylib", "dll"] as const;
const LEADING_PATH_PUNCTUATION = "'\"`([{<";
const TRAILING_PATH_PUNCTUATION = "'\"`)]}>.,;:!?";
const PATH_SPAN_END_PUNCTUATION = "'\"`)]}>.,;:!?";
const FILE_URI_PREFIX = "file://";
const HTTP_METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
"CONNECT",
"TRACE",
] as const;
const CLEAR_PROSE_BOUNDARIES = [
"after",
"because",
"before",
"but",
"crashed",
"denied",
"eacces",
"enoent",
"expired",
"failed",
"rejected",
"retry",
"then",
"when",
"while",
] as const;
const POSIX_FILESYSTEM_ROOTS = [
"/Users",
"/app",
"/boot",
"/data",
"/dev",
"/etc",
"/home",
"/media",
"/mnt",
"/nix",
"/opt",
"/private",
"/proc",
"/root",
"/run",
"/srv",
"/sys",
"/tmp",
"/usr",
"/var",
"/workspace",
] as const;
const WINDOWS_ROOT_RELATIVE_ROOTS = new Set([
"program files",
"programdata",
"temp",
"users",
"windows",
]);
function isWindowsAbsolutePathAt(value: string, start: number): boolean {
const remaining = value.length - start;
if (remaining > 2) {
const first = value.charCodeAt(start);
const second = value.charCodeAt(start + 1);
if ((first === 0x5c && second === 0x5c) || (first === 0x2f && second === 0x2f)) {
return true;
}
}
if (remaining < 3 || value.charCodeAt(start + 1) !== 0x3a) return false;
const driveLetter = value.charCodeAt(start);
const isAsciiLetter =
(driveLetter >= 0x41 && driveLetter <= 0x5a) || (driveLetter >= 0x61 && driveLetter <= 0x7a);
return (
isAsciiLetter && (value.charCodeAt(start + 2) === 0x2f || value.charCodeAt(start + 2) === 0x5c)
);
}
function isWindowsAbsolutePath(value: string): boolean {
return isWindowsAbsolutePathAt(value, 0);
}
function isWindowsRootRelativePathAt(value: string, start: number): boolean {
if (
value.charCodeAt(start) !== 0x5c ||
value.charCodeAt(start + 1) === 0x5c ||
isWhitespace(value[start + 1])
) {
return false;
}
const tokenEnd = findTokenEnd(value, start);
let firstSeparator = start + 1;
while (firstSeparator < tokenEnd && value.charCodeAt(firstSeparator) !== 0x5c) {
firstSeparator++;
}
const root = value.slice(start + 1, firstSeparator).toLowerCase();
if (WINDOWS_ROOT_RELATIVE_ROOTS.has(root)) return true;
return (
firstSeparator < tokenEnd - 1 || tokenContainsPathExtensionEvidence(value, start + 1, tokenEnd)
);
}
function hasAbsoluteFileUriAt(value: string, start: number): boolean {
const prefixEnd = start + FILE_URI_PREFIX.length;
return (
value.length > prefixEnd &&
value.slice(start, prefixEnd).toLowerCase() === FILE_URI_PREFIX &&
!isWhitespace(value[prefixEnd])
);
}
function hasAbsoluteFileUri(value: string): boolean {
return hasAbsoluteFileUriAt(value, 0);
}
function isSyntacticallyAbsolutePathAt(value: string, start: number): boolean {
return (
value.charCodeAt(start) === 0x2f ||
isWindowsAbsolutePathAt(value, start) ||
isWindowsRootRelativePathAt(value, start) ||
hasAbsoluteFileUriAt(value, start)
);
}
function isAsciiDigit(code: number): boolean {
return code >= 0x30 && code <= 0x39;
}
function isAsciiLetter(code: number): boolean {
return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
}
function isAsciiAlphaNumeric(code: number): boolean {
return isAsciiDigit(code) || isAsciiLetter(code);
}
function hasHttpUrlSchemeBefore(value: string, slashIndex: number): boolean {
for (const scheme of ["http:", "https:"]) {
const schemeStart = slashIndex - scheme.length;
if (schemeStart < 0 || value.slice(schemeStart, slashIndex).toLowerCase() !== scheme) continue;
if (schemeStart === 0 || !isAsciiAlphaNumeric(value.charCodeAt(schemeStart - 1))) return true;
}
return false;
}
function isWhitespace(value: string): boolean {
return /\s/.test(value);
}
function isRouteContextWord(value: string): boolean {
return value === "Route" || (HTTP_METHODS as readonly string[]).includes(value);
}
function hasRouteContextBefore(value: string, candidateIndex: number): boolean {
let index = candidateIndex - 1;
while (
index >= 0 &&
(isWhitespace(value[index]) ||
value.charCodeAt(index) === 0x28 ||
value.charCodeAt(index) === 0x3a)
) {
index--;
}
const contextEnd = index + 1;
while (index >= 0 && isAsciiAlphaNumeric(value.charCodeAt(index))) index--;
return isRouteContextWord(value.slice(index + 1, contextEnd));
}
function isRouteContextToken(value: string): boolean {
let end = value.length;
while (end > 0 && !isAsciiAlphaNumeric(value.charCodeAt(end - 1))) end--;
let start = end;
while (start > 0 && isAsciiAlphaNumeric(value.charCodeAt(start - 1))) start--;
return isRouteContextWord(value.slice(start, end));
}
function matchesPosixFilesystemRootAt(value: string, start: number, root: string): boolean {
if (!value.startsWith(root, start)) return false;
const rootEnd = start + root.length;
return (
rootEnd === value.length ||
value.charCodeAt(rootEnd) === 0x2f ||
PATH_SPAN_END_PUNCTUATION.includes(value[rootEnd])
);
}
function isKnownPosixFilesystemPathAt(value: string, start: number): boolean {
return POSIX_FILESYSTEM_ROOTS.some((root) => matchesPosixFilesystemRootAt(value, start, root));
}
function isKnownPosixFilesystemPath(value: string): boolean {
return isKnownPosixFilesystemPathAt(value, 0);
}
function looksLikeAbsolutePath(token: string): boolean {
// POSIX: common filesystem roots, with or without a source extension.
// Windows: drive-letter, UNC, or extended-length absolute paths.
// Source-file paths rooted elsewhere remain covered by SOURCE_EXT below.
if (token.length < 4 || token.length > 2048) return false;
const isPosix = token.charCodeAt(0) === 0x2f;
const isWindows = isWindowsAbsolutePath(token) || isWindowsRootRelativePathAt(token, 0);
if (!isPosix && !isWindows) return false;
if (isWindows) return true;
if (isKnownPosixFilesystemPath(token)) return true;
const dot = token.lastIndexOf(".");
if (dot <= 0 || dot === token.length - 1) return false;
const extension = token
.slice(dot + 1)
.split(":", 1)[0]
.toLowerCase();
return (
(SOURCE_EXT as readonly string[]).includes(extension) ||
(NATIVE_EXT as readonly string[]).includes(extension)
);
}
function redactAbsolutePathToken(token: string, followsRouteContext: boolean): string {
let start = 0;
let end = token.length;
while (start < end && LEADING_PATH_PUNCTUATION.includes(token[start])) start++;
while (end > start && TRAILING_PATH_PUNCTUATION.includes(token[end - 1])) end--;
const candidate = token.slice(start, end);
const isFileUri = hasAbsoluteFileUri(candidate);
const pathCandidate = isFileUri ? candidate.slice(FILE_URI_PREFIX.length) : candidate;
if (
!isFileUri &&
!isWindowsAbsolutePath(pathCandidate) &&
!isWindowsRootRelativePathAt(pathCandidate, 0) &&
pathCandidate.charCodeAt(0) === 0x2f &&
followsRouteContext
) {
return token;
}
if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token;
return `${token.slice(0, start)}<path>${token.slice(end)}`;
}
function findPathQuote(value: string, start: number, quote: string, takeFirst: boolean): number {
let candidate = value.indexOf(quote, start);
if (takeFirst || candidate < 0) return candidate < 0 ? value.length : candidate;
while (candidate < value.length) {
const nextQuote = value.indexOf(quote, candidate + 1);
if (nextQuote < 0) return candidate;
// Two separately quoted absolute paths are unambiguous. Close the first
// candidate so the second one is scanned on its own; otherwise keep
// consuming quotes fail-closed because POSIX filenames may contain them.
if (isSyntacticallyAbsolutePathAt(value, nextQuote + 1)) return candidate;
candidate = nextQuote;
}
return value.length;
}
function redactQuotedAbsolutePaths(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const quote = value[index];
if (quote !== "'" && quote !== '"' && quote !== "`") {
index++;
continue;
}
const candidateStart = index + 1;
if (!isSyntacticallyAbsolutePathAt(value, candidateStart)) {
index++;
continue;
}
const isShieldedRoute =
value.charCodeAt(candidateStart) === 0x2f &&
!isWindowsAbsolutePathAt(value, candidateStart) &&
hasRouteContextBefore(value, index);
// Route/API contexts use their first closing quote so a later quoted
// filesystem path is still scanned independently. Filesystem candidates
// take the last matching quote on the line: POSIX filenames may themselves
// contain quote characters, whitespace, and punctuation, so earlier
// matches are ambiguous and must fail closed rather than expose a suffix.
const closingQuote = findPathQuote(value, candidateStart, quote, isShieldedRoute);
if (isShieldedRoute) {
if (closingQuote >= value.length) break;
index = closingQuote + 1;
continue;
}
parts.push(value.slice(copyStart, candidateStart), "<path>");
copyStart = closingQuote;
if (closingQuote >= value.length) break;
index = closingQuote + 1;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function findPathExtensionEnd(value: string, dot: number): number {
let end = dot + 1;
const maxExtensionEnd = Math.min(value.length, end + 16);
while (end < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end))) end++;
if (end === dot + 1 || (end === maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end)))) {
return -1;
}
let hasLetter = false;
for (let index = dot + 1; index < end; index++) {
if (isAsciiLetter(value.charCodeAt(index))) hasLetter = true;
}
if (!hasLetter) return -1;
while (value.charCodeAt(end) === 0x3a) {
let coordinateEnd = end + 1;
if (!isAsciiDigit(value.charCodeAt(coordinateEnd))) break;
while (coordinateEnd < value.length && isAsciiDigit(value.charCodeAt(coordinateEnd))) {
coordinateEnd++;
}
end = coordinateEnd;
}
if (
end === value.length ||
isWhitespace(value[end]) ||
PATH_SPAN_END_PUNCTUATION.includes(value[end])
) {
return end;
}
return -1;
}
function findTokenEnd(value: string, start: number): number {
let end = start;
while (end < value.length && !isWhitespace(value[end])) end++;
return end;
}
function findExtensionEndInToken(value: string, start: number, end: number): number {
let lastExtensionEnd = -1;
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) {
lastExtensionEnd = -1;
continue;
}
if (code !== 0x2e) continue;
const extensionEnd = findPathExtensionEnd(value, index);
if (extensionEnd >= 0 && extensionEnd <= end) lastExtensionEnd = extensionEnd;
}
return lastExtensionEnd;
}
function tokenContainsPathExtensionEvidence(value: string, start: number, end: number): boolean {
for (let dot = start; dot < end; dot++) {
if (value.charCodeAt(dot) !== 0x2e) continue;
let extensionEnd = dot + 1;
const maxExtensionEnd = Math.min(end, extensionEnd + 16);
let hasLetter = false;
while (extensionEnd < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) {
if (isAsciiLetter(value.charCodeAt(extensionEnd))) hasLetter = true;
extensionEnd++;
}
if (
extensionEnd === dot + 1 ||
!hasLetter ||
(extensionEnd === maxExtensionEnd &&
extensionEnd < end &&
isAsciiAlphaNumeric(value.charCodeAt(extensionEnd)))
) {
continue;
}
if (
extensionEnd === end ||
value.charCodeAt(extensionEnd) === 0x2f ||
value.charCodeAt(extensionEnd) === 0x5c ||
PATH_SPAN_END_PUNCTUATION.includes(value[extensionEnd])
) {
return true;
}
}
return false;
}
function tokenContainsPathSeparator(value: string, start: number, end: number): boolean {
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) return true;
}
return false;
}
function remainderContainsFilesystemSeparator(value: string, start: number): boolean {
let tokenStart = start;
let previousToken = "";
while (tokenStart < value.length) {
while (tokenStart < value.length && isWhitespace(value[tokenStart])) tokenStart++;
if (tokenStart >= value.length) return false;
const tokenEnd = findTokenEnd(value, tokenStart);
const token = value.slice(tokenStart, tokenEnd).toLowerCase();
const isHttpUrl = token.includes("http://") || token.includes("https://");
let separatorIndex = tokenStart;
while (
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) !== 0x2f &&
value.charCodeAt(separatorIndex) !== 0x5c
) {
separatorIndex++;
}
const precedingSeparatorCode =
separatorIndex > tokenStart ? value.charCodeAt(separatorIndex - 1) : -1;
const contextIndex =
precedingSeparatorCode === 0x27 ||
precedingSeparatorCode === 0x22 ||
precedingSeparatorCode === 0x60
? separatorIndex - 1
: separatorIndex;
const isShieldedRoute =
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) === 0x2f &&
!isWindowsAbsolutePathAt(value, separatorIndex) &&
(isRouteContextToken(previousToken) || hasRouteContextBefore(value, contextIndex));
if (!isHttpUrl && separatorIndex < tokenEnd && !isShieldedRoute) return true;
previousToken = value.slice(tokenStart, tokenEnd);
tokenStart = tokenEnd;
}
return false;
}
function trimPathSpanEnd(value: string, start: number, end: number): number {
while (end > start && PATH_SPAN_END_PUNCTUATION.includes(value[end - 1])) end--;
return end;
}
function isClearProseBoundaryToken(value: string, start: number, end: number): boolean {
while (start < end && LEADING_PATH_PUNCTUATION.includes(value[start])) start++;
end = trimPathSpanEnd(value, start, end);
return (CLEAR_PROSE_BOUNDARIES as readonly string[]).includes(
value.slice(start, end).toLowerCase()
);
}
function findUnquotedPathEnd(
value: string,
start: number,
acceptFirstTokenPunctuation: boolean,
acceptEndpointBeforeAnotherAbsolute: boolean,
failClosedAmbiguity: boolean
): number {
let tokenStart = start;
let isFirstToken = true;
let firstTokenEnd = -1;
let firstTrimmedTokenEnd = -1;
let lastPathTokenEnd = -1;
let resolvedExtensionEnd = -1;
let hasFilesystemEvidence = false;
let hasUnresolvedFragments = false;
const resolveEndpoint = (): number => {
if (hasUnresolvedFragments) {
return failClosedAmbiguity || hasFilesystemEvidence ? value.length : -1;
}
if (resolvedExtensionEnd >= 0) return resolvedExtensionEnd;
if (hasFilesystemEvidence && lastPathTokenEnd >= 0) return lastPathTokenEnd;
if (
acceptFirstTokenPunctuation &&
firstTrimmedTokenEnd >= 0 &&
firstTrimmedTokenEnd < firstTokenEnd
) {
return firstTrimmedTokenEnd;
}
return -1;
};
while (tokenStart < value.length) {
const tokenEnd = findTokenEnd(value, tokenStart);
const extensionEnd = findExtensionEndInToken(value, tokenStart, tokenEnd);
const trimmedTokenEnd = trimPathSpanEnd(value, tokenStart, tokenEnd);
if (isFirstToken) {
firstTokenEnd = tokenEnd;
firstTrimmedTokenEnd = trimmedTokenEnd;
lastPathTokenEnd = trimmedTokenEnd;
// A prose-looking token may itself be a directory name. It is a safe
// boundary only when no later token carries path-separator evidence;
// otherwise keep scanning so a filesystem suffix cannot survive.
} else if (
isClearProseBoundaryToken(value, tokenStart, tokenEnd) &&
(!remainderContainsFilesystemSeparator(value, tokenEnd) ||
(!failClosedAmbiguity && !hasFilesystemEvidence))
) {
return resolveEndpoint();
}
const containsSeparator = tokenContainsPathSeparator(value, tokenStart, tokenEnd);
const containsExtensionEvidence = tokenContainsPathExtensionEvidence(
value,
tokenStart,
tokenEnd
);
if (containsSeparator) {
lastPathTokenEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
resolvedExtensionEnd = extensionEnd >= 0 ? extensionEnd : -1;
if (extensionEnd < 0 && containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
}
} else if (extensionEnd >= 0) {
resolvedExtensionEnd = extensionEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (!isFirstToken) {
hasUnresolvedFragments = true;
}
let nextTokenStart = tokenEnd;
while (nextTokenStart < value.length && isWhitespace(value[nextTokenStart])) nextTokenStart++;
if (nextTokenStart >= value.length) return resolveEndpoint();
if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) {
const endpoint = resolveEndpoint();
if (endpoint >= 0) return endpoint;
return acceptEndpointBeforeAnotherAbsolute ? lastPathTokenEnd : -1;
}
tokenStart = nextTokenStart;
isFirstToken = false;
}
return resolveEndpoint();
}
function isUnquotedPosixSpanCandidateAt(value: string, start: number): boolean {
const tokenEnd = findTokenEnd(value, start);
const token = value.slice(start, tokenEnd);
if (isKnownPosixFilesystemPath(token)) return true;
if (
findExtensionEndInToken(value, start, tokenEnd) >= 0 ||
tokenContainsPathExtensionEvidence(value, start, tokenEnd)
) {
return true;
}
let slashCount = 0;
for (let index = start; index < tokenEnd; index++) {
if (value.charCodeAt(index) === 0x2f) slashCount++;
}
// Any boundary-delimited absolute POSIX token is filesystem-sensitive by
// default. Explicit Route/HTTP context is shielded by the caller before this
// candidate check, so `/vault` is redacted while `Route /vault` is retained.
return slashCount >= 1 && token.length > 1;
}
function redactUnquotedAbsolutePathSpans(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const previous = index > 0 ? value[index - 1] : "";
const followsQuote = previous === "'" || previous === '"' || previous === "`";
const hasCommonBoundary =
index === 0 ||
isWhitespace(previous) ||
LEADING_PATH_PUNCTUATION.includes(previous) ||
previous === "=" ||
previous === ":" ||
previous === "," ||
previous === ";" ||
previous === "." ||
previous === ">" ||
previous === "|";
const startsForwardSlashUnc =
value.charCodeAt(index) === 0x2f && value.charCodeAt(index + 1) === 0x2f;
const startsHttpUrl =
startsForwardSlashUnc && previous === ":" && hasHttpUrlSchemeBefore(value, index);
const isWindowsPath =
!followsQuote &&
(isWindowsAbsolutePathAt(value, index) || isWindowsRootRelativePathAt(value, index)) &&
!startsHttpUrl;
const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index);
const isPosixPath =
!followsQuote &&
value.charCodeAt(index) === 0x2f &&
value.charCodeAt(index + 1) !== 0x2f &&
!hasRouteContextBefore(value, index) &&
isUnquotedPosixSpanCandidateAt(value, index);
const hasBoundary = hasCommonBoundary || (isWindowsPath && previous === ":");
if (!hasBoundary || (!isWindowsPath && !isFileUriPath && !isPosixPath)) {
index++;
continue;
}
// Whitespace makes an unquoted path ambiguous. Extend through adjacent
// separator-bearing tokens or to a deterministic filename extension.
// Unequivocal Windows, file-URI, and known-root candidates fail closed;
// arbitrary extensionless POSIX text falls back to token-level handling so
// ordinary `/x/y` route text is not redacted indiscriminately.
const isKnownPosixPath = isKnownPosixFilesystemPathAt(value, index);
const pathEnd = findUnquotedPathEnd(
value,
index,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath
);
if (pathEnd < 0) {
const mustFailClosed = isWindowsPath || isFileUriPath || isKnownPosixPath;
if (mustFailClosed) {
// An unequivocal filesystem prefix with an unknowable endpoint must
// fail closed over the rest of the first line rather than expose a
// suffix such as `Files\\secret` or `My Project`.
parts.push(value.slice(copyStart, index), "<path>");
copyStart = value.length;
index = value.length;
break;
}
index++;
continue;
}
parts.push(value.slice(copyStart, index), "<path>");
copyStart = pathEnd;
index = pathEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function isPhysicalLineSeparator(code: number): boolean {
return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029;
}
function serializedLineSeparatorLengthAt(value: string, start: number): number {
if (value.charCodeAt(start) !== 0x5c) return 0;
const marker = value[start + 1]?.toLowerCase();
if (marker === "n" || marker === "r") return 2;
const unicodeMarker = value.slice(start + 1, start + 6).toLowerCase();
return unicodeMarker === "u000a" ||
unicodeMarker === "u000d" ||
unicodeMarker === "u2028" ||
unicodeMarker === "u2029"
? 6
: 0;
}
function looksLikeRelativeStackLocation(token: string): boolean {
if (token.length < 6 || token.length > 2048) return false;
const lastForwardSlash = token.lastIndexOf("/");
const lastBackslash = token.lastIndexOf("\\");
const lastSeparator = Math.max(lastForwardSlash, lastBackslash);
if (lastSeparator === token.length - 1) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator < 0 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
const queryIndex = token.indexOf("?", lastSeparator + 1);
const fragmentIndex = token.indexOf("#", lastSeparator + 1);
const metadataIndexes = [queryIndex, fragmentIndex].filter(
(index) => index >= 0 && index < lineSeparator
);
const extensionEnd = metadataIndexes.length > 0 ? Math.min(...metadataIndexes) : lineSeparator;
const dot = token.lastIndexOf(".", extensionEnd - 1);
if (dot <= lastSeparator || dot === extensionEnd - 1) return false;
const extension = token.slice(dot + 1, extensionEnd).toLowerCase();
if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false;
return true;
}
function looksLikeUrlStackLocation(token: string): boolean {
if (token.length < 12 || token.length > 2048) return false;
const lower = token.toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
return lineSeparator > 0 && hasNumericLineColumnSuffix(token, lineSeparator);
}
function hasNumericLineColumnSuffix(value: string, separator: number): boolean {
if (value.charCodeAt(separator) !== 0x3a) return false;
let index = separator + 1;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
if (value.charCodeAt(index) !== 0x3a) return false;
index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
return index === value.length;
}
function isNodeModulePathCode(code: number): boolean {
return (
isAsciiAlphaNumeric(code) || code === 0x2e || code === 0x2f || code === 0x5f || code === 0x2d
);
}
function looksLikeNodeStackLocation(token: string): boolean {
if (token.length < 10 || token.length > 2048 || !token.startsWith("node:")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator <= 5 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
for (let index = 5; index < lineSeparator; index++) {
if (!isNodeModulePathCode(token.charCodeAt(index))) return false;
}
return true;
}
function looksLikeEvalStackLocation(token: string): boolean {
return token.length <= 64 && token.startsWith("[eval]") && hasNumericLineColumnSuffix(token, 6);
}
function isRecognizedStackPathAt(value: string, start: number): boolean {
if (hasAbsoluteFileUriAt(value, start)) return true;
const tokenEnd = trimPathSpanEnd(value, start, findTokenEnd(value, start));
const token = value.slice(start, tokenEnd);
return (
looksLikeAbsolutePath(token) ||
looksLikeRelativeStackLocation(token) ||
looksLikeUrlStackLocation(token) ||
looksLikeNodeStackLocation(token) ||
looksLikeEvalStackLocation(token)
);
}
function isStackFrameLabel(value: string, start: number, end: number): boolean {
const label = value.slice(start, end).trim();
if (label.length === 0 || label.length > 256) return false;
if (!/^[A-Za-z_$<]/.test(label) || /[^A-Za-z0-9_$.[\]<>:/ -]/.test(label)) return false;
if (!/\s/.test(label)) return true;
return /^(?:async|new)\s+\S+$/.test(label) || /^\S+\s+\[as\s+\S+\]$/.test(label);
}
function skipAsyncStackPrefix(value: string, start: number): number {
if (value.slice(start, start + 5) !== "async" || !isWhitespace(value[start + 5])) return start;
let locationStart = start + 6;
while (locationStart < value.length && isWhitespace(value[locationStart])) locationStart++;
return locationStart;
}
function isAggregateIndexLocationAt(value: string, start: number): boolean {
if (value.slice(start, start + 5) !== "index" || !isWhitespace(value[start + 5])) return false;
let index = start + 6;
while (index < value.length && isWhitespace(value[index])) index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
while (index < value.length && isWhitespace(value[index])) index++;
return value.charCodeAt(index) === 0x29;
}
function looksLikeStackFrameAt(value: string, atIndex: number, allowDirectPath: boolean): boolean {
if (value.slice(atIndex, atIndex + 2).toLowerCase() !== "at") return false;
let labelStart = atIndex + 2;
if (!isWhitespace(value[labelStart])) return false;
while (labelStart < value.length && isWhitespace(value[labelStart])) labelStart++;
labelStart = skipAsyncStackPrefix(value, labelStart);
if (allowDirectPath && isRecognizedStackPathAt(value, labelStart)) return true;
const openParen = value.indexOf("(", labelStart);
if (openParen < 0 || openParen - labelStart > 256) return false;
let pathStart = openParen + 1;
while (pathStart < value.length && isWhitespace(value[pathStart])) pathStart++;
return (
isStackFrameLabel(value, labelStart, openParen) &&
(isRecognizedStackPathAt(value, pathStart) ||
(allowDirectPath && isAggregateIndexLocationAt(value, pathStart)))
);
}
function looksLikeAtSignStackFrameAt(value: string, frameStart: number): boolean {
const tokenEnd = trimPathSpanEnd(value, frameStart, findTokenEnd(value, frameStart));
const atSign = value.indexOf("@", frameStart);
if (atSign <= frameStart || atSign >= tokenEnd || atSign - frameStart > 256) return false;
return isStackFrameLabel(value, frameStart, atSign) && isRecognizedStackPathAt(value, atSign + 1);
}
function findSerializedStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = serializedLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length) {
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
const adjacentSeparatorLength = serializedLineSeparatorLengthAt(value, frameStart);
if (adjacentSeparatorLength === 0) break;
frameStart += adjacentSeparatorLength;
}
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
let separatorStart = index;
while (separatorStart > 0 && value.charCodeAt(separatorStart - 1) === 0x5c) {
separatorStart--;
}
return separatorStart;
}
}
return -1;
}
function findInlineStackFrameStart(value: string): number {
let marker = value.indexOf(" at ");
while (marker >= 0) {
if (looksLikeStackFrameAt(value, marker + 1, false)) return marker;
marker = value.indexOf(" at ", marker + 4);
}
return -1;
}
function findInlineAtSignStackFrameStart(value: string): number {
let frameStart = 0;
while (frameStart < value.length) {
if (looksLikeAtSignStackFrameAt(value, frameStart)) {
return frameStart > 0 && isWhitespace(value[frameStart - 1]) ? frameStart - 1 : frameStart;
}
const tokenEnd = findTokenEnd(value, frameStart);
frameStart = tokenEnd;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
}
return -1;
}
function physicalLineSeparatorLengthAt(value: string, start: number): number {
const code = value.charCodeAt(start);
if (!isPhysicalLineSeparator(code)) return 0;
return code === 0x0d && value.charCodeAt(start + 1) === 0x0a ? 2 : 1;
}
function findPhysicalStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = physicalLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
return index;
}
index += separatorLength - 1;
}
return -1;
}
/** Strip only recognized physical, serialized, and inline JavaScript stack-frame tails. */
export function stripRecognizedErrorStackTail(value: string): string {
const candidates = [
findPhysicalStackFrameStart(value),
findSerializedStackFrameStart(value),
findInlineStackFrameStart(value),
findInlineAtSignStackFrameStart(value),
].filter((candidate) => candidate >= 0);
if (candidates.length === 0) return value;
return value.slice(0, Math.min(...candidates));
}
/**
* Public exception messages remain fail-closed at the first physical line.
* Provider passthroughs that require multiline capability wording use the
* narrower recognized-frame helper above instead.
*/
export function stripErrorStackTail(value: string): string {
let firstLineEnd = value.length;
for (let index = 0; index < value.length; index++) {
if (isPhysicalLineSeparator(value.charCodeAt(index))) {
firstLineEnd = index;
break;
}
}
return stripRecognizedErrorStackTail(value.slice(0, firstLineEnd));
}
/**
* Redact absolute filesystem paths while preserving URLs, explicitly marked
* API routes, and punctuation around determinable endpoints. Unequivocal
* filesystem prefixes fail closed when an unquoted endpoint is ambiguous.
*/
export function redactErrorPaths(value: string): string {
const quotedPathsRedacted = redactQuotedAbsolutePaths(value);
const pathSpansRedacted = redactUnquotedAbsolutePathSpans(quotedPathsRedacted);
const parts = pathSpansRedacted.split(/(\s+)/);
let previousToken = "";
for (let index = 0; index < parts.length; index++) {
const token = parts[index];
if (isWhitespace(token)) continue;
parts[index] = redactAbsolutePathToken(token, isRouteContextToken(previousToken));
previousToken = token;
}
return parts.join("");
}