mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
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:
committed by
GitHub
parent
8a95a2bced
commit
4a37c7f46e
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user