diff --git a/CHANGELOG.md b/CHANGELOG.md index 10518c4765de..ee5aa630a5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - **fix(cli-tools):** saving the OpenCode/CLI tool config no longer 400s in cloud mode — every CLI tool card posts `apiKey: null` (the real key is resolved server-side from `keyId`), but `guideSettingsSaveSchema` used `z.string().optional()`, which rejects `null`. The schema now normalizes `null` → `undefined`, so the save succeeds and the `keyId`/default path is used. ([#3552](https://github.com/diegosouzapw/OmniRoute/issues/3552)) - **fix(catalog):** PublicAI is no longer miscatalogued as keyless/free — it requires an API key (registry `authType:"apikey"`; signup grants a one-time credit, then it bills). The three PublicAI models moved from `freeType:"keyless"` (which could pick them into the no-auth pool and dispatch with no `Authorization` header) to `"one-time-initial"`, and the provider's `hasFree` flag is now `false` — matching `freeTierCatalog.ts`, which already excluded publicai. ([#3558](https://github.com/diegosouzapw/OmniRoute/issues/3558)) - **fix(gemini-web):** a missing Playwright Chromium browser no longer loops and trips the provider breaker — when the browser binary is not installed, `chromium.launch()` threw an error surfaced as a retryable **500**, so accountFallback marked the account unavailable and retry-looped. It is now classified as a host/config problem and returns **503** with an actionable message (`npx playwright install chromium`) and the `X-Omni-Fallback-Hint: connection_cooldown` header, which skips the provider circuit breaker and applies a short non-exponential cooldown. ([#3516](https://github.com/diegosouzapw/OmniRoute/issues/3516)) +- **fix(security):** route raw `err.message` through `sanitizeErrorMessage()` in five web executors (`adapta-web`, `deepseek-web`, `perplexity-web`, `qoder`, `veoaifree-web`) and the embeddings + search handlers (Hard Rule #12) — these built error response bodies from the raw upstream/exception message, which could leak internal detail. ([#3494](https://github.com/diegosouzapw/OmniRoute/issues/3494), [#3495](https://github.com/diegosouzapw/OmniRoute/issues/3495)) --- diff --git a/open-sse/executors/adapta-web.ts b/open-sse/executors/adapta-web.ts index b144bd826722..b94f3cfcae93 100644 --- a/open-sse/executors/adapta-web.ts +++ b/open-sse/executors/adapta-web.ts @@ -1,5 +1,6 @@ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; const ADAPTA_APP_URL = "https://agent.adapta.one"; const ADAPTA_CLERK_URL = "https://clerk.agent.adapta.one"; @@ -378,7 +379,7 @@ export class AdaptaWebExecutor extends BaseExecutor { const msg = err instanceof Error ? err.message : String(err); log?.warn?.("ADAPTA-WEB", `Auth failed: ${msg}`); return { - response: makeErrorResponse(401, `Adapta auth failed: ${msg}`), + response: makeErrorResponse(401, `Adapta auth failed: ${sanitizeErrorMessage(msg)}`), url: ADAPTA_STREAM_URL, headers: {}, transformedBody: body, diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 55b10aa2dae8..242973924fd0 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -5,6 +5,7 @@ import { parseToolCallsFromText, type OpenAIToolCall, } from "../translator/webTools.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com"; const DEEPSEEK_API_BASE = `${DEEPSEEK_WEB_BASE}/api`; @@ -1100,7 +1101,7 @@ export class DeepSeekWebExecutor extends BaseExecutor { } return { - response: errorResponse(502, `DeepSeek error: ${msg}`), + response: errorResponse(502, `DeepSeek error: ${sanitizeErrorMessage(msg)}`), url: COMPLETION_URL, headers: {}, transformedBody: body, diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 6d694ac59d77..bca64552aed7 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -14,6 +14,7 @@ import { type TlsFetchResult, } from "../services/perplexityTlsClient.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; const PPLX_API_VERSION = "client-1.11.0"; @@ -756,8 +757,8 @@ export class PerplexityWebExecutor extends BaseExecutor { JSON.stringify({ error: { message: isTlsUnavail - ? `Perplexity TLS client unavailable: ${(err as Error).message}` - : `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`, + ? `Perplexity TLS client unavailable: ${sanitizeErrorMessage((err as Error).message)}` + : `Perplexity connection failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`, type: "upstream_error", }, }), diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index 97b8af595e56..3c96e6b61a37 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -12,6 +12,7 @@ import { } from "../config/providerHeaderProfiles.ts"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; import { buildCosyHeadersForValidation } from "../services/qoderCli.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; function getAuthToken(credentials: ProviderCredentials): string { if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) { @@ -230,7 +231,7 @@ export class QoderExecutor extends BaseExecutor { response: new Response( JSON.stringify({ error: { - message: `Qoder fetch error: ${error.message}`, + message: `Qoder fetch error: ${sanitizeErrorMessage(error.message)}`, type: "provider_error", }, }), diff --git a/open-sse/executors/veoaifree-web.ts b/open-sse/executors/veoaifree-web.ts index 9cfeedb5a9ec..56e81ae41c48 100644 --- a/open-sse/executors/veoaifree-web.ts +++ b/open-sse/executors/veoaifree-web.ts @@ -7,6 +7,7 @@ * No auth required. Rate limited to 6 requests/hour per IP. */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; const BASE_URL = "https://veoaifree.com"; const AJAX_URL = `${BASE_URL}/wp-admin/admin-ajax.php`; @@ -362,7 +363,7 @@ export class VeoAIFreeWebExecutor extends BaseExecutor { nonce = await fetchNonce(input.signal); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to get nonce"; - return { response: errResp(msg), url: BASE_URL, headers: {}, transformedBody: null }; + return { response: errResp(sanitizeErrorMessage(msg)), url: BASE_URL, headers: {}, transformedBody: null }; } // Extract aspect ratio from system prompt or default diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index a85cefc92106..e3ea746064e1 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -24,6 +24,7 @@ import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs"; import { getCallLogPipelineCaptureStreamChunks } from "@/lib/logEnv"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { stripStaleEncodingHeaders } from "../utils/upstreamResponseHeaders.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; interface ClientRawRequest { endpoint: string; @@ -339,7 +340,7 @@ export async function handleEmbedding({ return { success: false, status: 502, - error: `Embedding provider error: ${err.message}`, + error: `Embedding provider error: ${sanitizeErrorMessage(err.message)}`, }; } } diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index b86c22dcac85..218705149301 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -22,6 +22,7 @@ import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { z } from "zod"; +import { sanitizeErrorMessage } from "../utils/error.ts"; // ── Types ──────────────────────────────────────────────────────────────── @@ -1164,7 +1165,7 @@ async function tryZaiMCPProvider( return { success: false, status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`, + error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, }; } } @@ -1434,7 +1435,7 @@ async function tryProvider( return { success: false, status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`, + error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, }; } } diff --git a/scripts/check/check-error-helper.mjs b/scripts/check/check-error-helper.mjs index 56a0ac869906..05a577e9d09c 100644 --- a/scripts/check/check-error-helper.mjs +++ b/scripts/check/check-error-helper.mjs @@ -31,35 +31,7 @@ const SCAN_DIRS = [ // with no utils/error import) and should become a tracked cleanup issue: route the // message through sanitizeErrorMessage()/buildErrorBody()/makeExecutorErrorResult(). // Do NOT add new entries without a justification — that defeats the gate. -export const KNOWN_MISSING_ERROR_HELPER = new Set([ - // adapta-web: local makeErrorResponse() + `Adapta auth failed: ${msg}` where - // msg = err.message — raw auth/upstream error string in the JSON error body, - // no open-sse/utils/error import. Fix: sanitizeErrorMessage(msg) before forwarding. - "open-sse/executors/adapta-web.ts", - // deepseek-web: local errorResponse() shadow that puts `message` raw into the body, - // fed `DeepSeek error: ${msg}` where msg = err.message — bypasses the canonical - // sanitizer. Fix: route through buildErrorBody()/sanitizeErrorMessage(). - "open-sse/executors/deepseek-web.ts", - // perplexity-web: `new Response({ error: { message: `Perplexity connection failed: - // ${err.message}` }})` (multi-line envelope) for TLS/connection failures — raw - // err.message in the client error body, no sanitizer import. - "open-sse/executors/perplexity-web.ts", - // qoder: `response: new Response({ error: { message: `Qoder fetch error: - // ${error.message}` }})` — raw error.message in the returned response body, - // no sanitizer import. - "open-sse/executors/qoder.ts", - // veoaifree-web: local errResp(msg) on nonce-fetch failure where msg = err.message — - // raw error string in the response body, no sanitizer import. - "open-sse/executors/veoaifree-web.ts", - // embeddings handler: `return { success: false, status: 502, error: `Embedding - // provider error: ${err.message}` }` — raw err.message in the result error field, - // no sanitizer import. (The saveCallLog `error: err.message` rows are internal and - // correctly NOT what is frozen here.) - "open-sse/handlers/embeddings.ts", - // search handler: `return { …, error: `Search provider …: ${err.message}` }` — raw - // err.message in the result error field, no sanitizer import. - "open-sse/handlers/search.ts", -]); +export const KNOWN_MISSING_ERROR_HELPER = new Set([]); // Import specifiers that count as "uses the error helper" (path ends in utils/error). const ERROR_HELPER_IMPORT =