mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
Three defects the #12506 hardening left behind, each red on the base tip: - SAFE_PUBLIC_ERROR_IDENTIFIERS listed 250 codes while the code emits 49 more (huggingchat_generation_error, the uc_* family, zai_stream_error, provider_circuit_open, model_lockout, heap_pressure, unsupported_media_type, upgrade_required, …). Every one of them was silently rewritten to the generic status-derived code, so clients lost the specific reason. Each added code was verified against its emitting call site. - The path redactor swallowed the credential marker an earlier pass had already written: 'TLS request failed at /srv/…/client.ts:44:9 access_token=[REDACTED]' collapsed to a bare '<path>', hiding which credential leaked. The span now ends at a redaction marker. - Labeled credential assignments (api_key=…, access_token=…) are projected before the path tokenizer for the same reason raw URI credentials already were, otherwise the path span absorbs the assignment before it can be marked. The tunnel characterization test now states, per leak shape, what the shared sanitizer covers: the four path-shaped leaks are covered upstream now, the tailscale auth key is not — which is exactly why publicSafeTunnelError stays. The public-body assertions are untouched.
930 lines
33 KiB
TypeScript
930 lines
33 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 = (ignoreAmbiguity = false): number => {
|
|
if (hasUnresolvedFragments && !ignoreAmbiguity) {
|
|
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();
|
|
// A redaction marker ends the span: whatever follows was already made safe
|
|
// by the credential pass, and swallowing it would erase that evidence.
|
|
if (startsRedactedToken(value, nextTokenStart)) return resolveEndpoint(true);
|
|
if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) {
|
|
// A route-shielded upcoming span (e.g. "POST /v1/foo") is never
|
|
// filesystem-sensitive by design — see hasRouteContextBefore. Its mere
|
|
// presence must not force ambiguous prose in between (like "Use POST")
|
|
// to fail closed and swallow past it into the shielded route and
|
|
// beyond; resolve with whatever evidence was already gathered instead.
|
|
const endpoint = resolveEndpoint(hasRouteContextBefore(value, nextTokenStart));
|
|
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.
|
|
*/
|
|
/**
|
|
* `[REDACTED]` is the marker an earlier sanitizer pass already wrote over a
|
|
* credential. It is never part of a filesystem path, and a path span that grows
|
|
* across it costs the operator the one piece of evidence that pass left behind:
|
|
* "TLS request failed at /srv/…/client.ts:44:9 access_token=[REDACTED]"
|
|
* collapsed to a bare "<path>", hiding *which* credential leaked.
|
|
*/
|
|
const REDACTION_MARKER = "[REDACTED]";
|
|
|
|
/** True when the token starting at `index` carries a redaction marker. */
|
|
function startsRedactedToken(value: string, index: number): boolean {
|
|
let end = index;
|
|
while (end < value.length && !isWhitespace(value[end])) end++;
|
|
return value.slice(index, end).includes(REDACTION_MARKER);
|
|
}
|
|
|
|
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("");
|
|
}
|