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

70 lines
3.1 KiB
TypeScript

import {
containsSensitiveErrorCredential,
sanitizePassthroughUpstreamDetails,
} from "./errorSanitization.ts";
/**
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
*
* Claude Code matches upstream error wording to auto-disable capabilities
* (thinking / output_config) for the rest of the conversation. This path keeps
* the wording and JSON shape required for that recovery after applying the
* canonical recursive sanitizer. OmniRoute-generated errors MUST keep using
* buildErrorBody() (Hard Rule #12).
*/
const PASSTHROUGH_MIN = 400;
const PASSTHROUGH_MAX = 499;
// 401/403/407: auth-adjacent — our own credential context may leak via provider
// echoes; keep those sanitized. 400/404/408/413/422/429 carry the capability and
// quota wording the client needs.
const EXCLUDED_STATUSES = new Set([401, 403, 407]);
const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i;
// #10898-sec / secret-in-error hardening: some providers echo the offending
// request (including an Authorization header or api key) inside a 400/422/429
// validation body. If the body carries a credential pattern, REFUSE passthrough
// before the recursive sanitizer so the caller falls back to buildErrorBody.
// Eligible JSON retains its safe shape and capability/quota wording after the
// recursive projection. Mirrors redactSensitiveErrorText in errorSanitization.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;
export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean {
if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false;
if (EXCLUDED_STATUSES.has(statusCode)) return false;
if (!upstreamBody || typeof upstreamBody !== "object") return false;
let text: string | undefined;
try {
text = JSON.stringify(upstreamBody);
} catch {
// Relay only JSON-stable objects; cyclic/BigInt/hostile toJSON bodies fail closed.
return false;
}
if (typeof text !== "string") return false;
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) || containsSensitiveErrorCredential(text)) return false;
return true;
}
export function buildPassthroughErrorResponse(
statusCode: number,
upstreamBody: unknown,
headers?: Record<string, string>
): Response | null {
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
try {
const sanitizedBody = sanitizePassthroughUpstreamDetails(upstreamBody);
const publicBody =
sanitizedBody && typeof sanitizedBody === "object"
? sanitizedBody
: { error: { message: "Upstream error" } };
return new Response(JSON.stringify(publicBody), {
status: statusCode,
headers: { "Content-Type": "application/json", ...(headers || {}) },
});
} catch {
// A proxy/getter may behave differently between eligibility and projection.
return null;
}
}