mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
Validated on the resolved merge against the current tip (527da656 + the post-#11281 rebaseline): the single conflict was a comment-only collision in providers/[id]/models/route.ts (kept the tip's #10828-ordering note). Focused suites 125/125 across all 13 touched test files (build-sqlite-stub, cc-compatible, copilot-claude-messages, copilot-gemini-route, executor-github, ghe-copilot, github-copilot-discovery-token, github-copilot-model-discovery, noauth-sibling-7620, provider-header-profiles, provider-models-config, request-log-payloads, upstream-error-passthrough), typecheck:core clean, file-size/changelog-integrity OK. Merged --admin over the inherited 2026-08-23 base-red cluster (#9985) — the reds are proven tip failures (CLI catalog cluster + @testing-library allowlist, being drained by #11280), not from this diff. Note: the rebase means several items the body listed (relay x-relay-path SSRF, /v1/search blocked-providers, #10736 rotation fence, #10903, #10865, #10899, #10916) already landed upstream and are NOT in this delta — the delta is: better-sqlite3 build guard + build heap/worker caps + telemetry-off (#10060 re-derived), credential-echo passthrough refusal + OCR/moderation redaction + call-log key redaction, Copilot CLI 1.0.81-6 wire identity + Claude→/v1/messages name-matched routing + discovery token fix, CC model_not_found 400, compat overrides for no-auth aliases (#7620-pinned). The Copilot wire-identity change is the one to watch in production. Thank you @arminanton — and the ported-author credits in the commit history (@rqzbeh, yidecode, the #10899/#10916 authors) are preserved. Your config-posture finding (REQUIRE_API_KEY default vs 0.0.0.0) is noted for a maintainer decision, as you scoped it.
52 lines
2.7 KiB
TypeScript
52 lines
2.7 KiB
TypeScript
/**
|
|
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
|
|
*
|
|
* Claude Code matches the upstream error WORDING to auto-disable capabilities
|
|
* (thinking / output_config) for the rest of the conversation. Wrapping the body
|
|
* via buildErrorBody() truncates the message and breaks that recovery. For
|
|
* upstream-originated 4xx errors the body is the provider's public API message —
|
|
* not our internals — so it is safe and required to relay it verbatim.
|
|
* 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. Passthrough relays the body VERBATIM (the Claude Code
|
|
// capability-recovery contract needs the exact wording), so we cannot key-drop
|
|
// via sanitizeUpstreamDetails without breaking that contract. Instead, if the
|
|
// body actually carries a credential pattern, REFUSE passthrough and let the
|
|
// 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;
|
|
|
|
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;
|
|
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;
|
|
return true;
|
|
}
|
|
|
|
export function buildPassthroughErrorResponse(
|
|
statusCode: number,
|
|
upstreamBody: unknown,
|
|
headers?: Record<string, string>
|
|
): Response | null {
|
|
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
|
|
return new Response(JSON.stringify(upstreamBody), {
|
|
status: statusCode,
|
|
headers: { "Content-Type": "application/json", ...(headers || {}) },
|
|
});
|
|
}
|