fix(security): close 3 advisories — search baseUrl exfil, sk- in the error sanitizer, bifrost relay header leak (#12620)

Validado em worktree combinada sobre o tip de release/v3.8.51: os dois boardaram sem conflito, typecheck:core limpo, check-file-size sem violação nova (as duas restantes — codex.ts e stream.ts — são drift anterior) e 51/51 nos 5 arquivos de teste que os PRs trazem.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-03 20:48:58 -03:00
committed by GitHub
parent 8a95a2bced
commit 4a37c7f46e
13 changed files with 570 additions and 30 deletions

View File

@@ -40,8 +40,36 @@ function looksLikeAbsolutePath(tok: string): boolean {
return (SOURCE_EXT as readonly string[]).includes(ext);
}
/**
* Raw credential shapes that carry no `key=` label to key off — the token IS the
* whole match, so the only way to redact them is to recognize the shape.
*
* GHSA-qv45-56jc-4wmj: `upstreamErrorPassthrough.ts` already recognized `sk-`
* and refused verbatim passthrough for bodies containing it, then handed those
* bodies to THIS sanitizer — which had no such pattern, so the key came back to
* the caller anyway. The passthrough file's comment claimed to "mirror the
* vocabulary of redactSensitiveErrorText"; the mirror had drifted. It now
* imports this array instead of keeping a second copy, so the two cannot drift
* again.
*
* Quantifiers are upper-bounded (AGENTS.md → PII learnings §1, ReDoS): these run
* over untrusted upstream error bodies.
*/
export const RAW_CREDENTIAL_PATTERNS: ReadonlyArray<RegExp> = [
// OpenAI/Anthropic/Stripe-style secret keys: sk-…, sk-ant-…, sk_live_…
/\bsk[-_][A-Za-z0-9._-]{8,200}/g,
// Google API keys
/\bAIza[A-Za-z0-9_-]{20,200}/g,
// JWTs (three base64url segments)
/\beyJ[A-Za-z0-9_-]{8,400}\.[A-Za-z0-9_-]{8,800}\.[A-Za-z0-9_-]{8,800}/g,
];
export function redactSensitiveErrorText(value: string): string {
return value
let out = value;
for (const pattern of RAW_CREDENTIAL_PATTERNS) {
out = out.replace(pattern, "[REDACTED_CREDENTIAL]");
}
return out
.replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(

View File

@@ -1,3 +1,4 @@
import { RAW_CREDENTIAL_PATTERNS } from "./error.ts";
/**
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
*
@@ -24,8 +25,23 @@ const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i;
// caller fall back to the sanitized buildErrorBody path. Bodies without a
// secret (the overwhelming majority, carrying capability/quota wording) still
// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts.
const CREDENTIAL_LEAK_RE =
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
const LABELLED_CREDENTIAL_RE =
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
/**
* The raw-token shapes (sk-…, AIza…, JWT) come from error.ts's
* RAW_CREDENTIAL_PATTERNS rather than a second local copy. The previous local
* copy carried `sk-` while the sanitizer this file falls back to did NOT, so a
* body recognized as leaky here was returned unredacted there
* (GHSA-qv45-56jc-4wmj). One source, no drift.
*/
function containsCredential(text: string): boolean {
if (LABELLED_CREDENTIAL_RE.test(text)) return true;
return RAW_CREDENTIAL_PATTERNS.some((pattern) => {
pattern.lastIndex = 0; // the shared patterns are /g — reset before .test()
return pattern.test(text);
});
}
export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean {
if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false;
@@ -34,7 +50,7 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody:
const text = JSON.stringify(upstreamBody);
if (INTERNAL_LEAK_RE.test(text)) return false;
// Refuse passthrough when the provider echoed a credential back to us.
if (CREDENTIAL_LEAK_RE.test(text)) return false;
if (containsCredential(text)) return false;
return true;
}

View File

@@ -45,3 +45,36 @@ export function filterUpstreamResponseHeaderEntries(
}
export const STRIP_UPSTREAM_HEADER_NAMES: ReadonlySet<string> = STRIP_HEADER_NAMES;
/**
* Response headers that must never be relayed back to a client.
*
* A relay sends its own credential upstream (the bifrost route sends
* `Authorization: Bearer ${BIFROST_API_KEY}` to the sidecar). If that upstream
* echoes the header back — or sets its own session cookie — copying the response
* headers wholesale hands it to whoever holds the relay token
* (GHSA-9m72-44hg-w32g). `set-cookie` matters as much as `authorization`: it is
* a session, and the browser would store it against OUR origin.
*/
const SENSITIVE_RESPONSE_HEADER_NAMES: ReadonlyArray<string> = [
"authorization",
"proxy-authorization",
"x-api-key",
"x-goog-api-key",
"api-key",
"cookie",
"set-cookie",
];
/**
* New Headers with the stale framing set AND any echoed credential/session
* header removed. Use this instead of `new Headers(upstream.headers)` on every
* path that relays an upstream response to a client. Does not mutate the input.
*/
export function stripSensitiveResponseHeaders(input: Headers): Headers {
return new Headers(
filterUpstreamResponseHeaderEntries(input.entries(), SENSITIVE_RESPONSE_HEADER_NAMES)
);
}
export { SENSITIVE_RESPONSE_HEADER_NAMES };