mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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.
70 lines
3.1 KiB
TypeScript
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;
|
|
}
|
|
}
|