Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **security(runtime):** fail closed on hostile thrown values and keep upstream text out of public error surfaces — the chat pipeline now reads rejection metadata through a safe accessor, sanitizes the message before it reaches call logs and console, and projects the failure-usage code onto the bounded public vocabulary; Perplexity's non-streaming quota/upstream error body sanitizes the upstream message and projects the provider-supplied error code; Arena (lmarena) maps every public failure onto a fixed vocabulary instead of echoing the upstream error; Notion's TLS transport failure sanitizes the transport error before it reaches the response body ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)).
15 changes: 0 additions & 15 deletions config/quality/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,6 @@
"count": 1
}
},
"open-sse/executors/chipotle.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"open-sse/executors/claude-web.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
Expand Down Expand Up @@ -164,11 +159,6 @@
"count": 2
}
},
"open-sse/executors/notion-web.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"open-sse/executors/opencode.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
Expand Down Expand Up @@ -2807,11 +2797,6 @@
"count": 1
}
},
"tests/unit/chipotle-executor.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"tests/unit/claude-code-compatible-helpers.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
Expand Down
5 changes: 3 additions & 2 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@
"open-sse/executors/codex.ts": 1528,
"open-sse/executors/cursor.ts": 1847,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 6219,
"open-sse/handlers/chatCore.ts": 6287,
"open-sse/handlers/imageGeneration.ts": 3304,
"open-sse/handlers/search.ts": 1789,
"open-sse/mcp-server/schemas/tools.ts": 1621,
Expand Down Expand Up @@ -703,5 +703,6 @@
"_rebaseline_2026_09_07_roundrobin_crosses_new_file_cap": "open-sse/services/combo/roundRobinCombo.ts 1198->1205, crossing the 1200 new-file cap. #12884 wires the quota-skip diagnostics into the round-robin attempt path so an ALL_TARGETS_SKIPPED 503 names which windows were exhausted instead of returning an opaque skip. The file was already at 1198 when #12811 lifted it out of combo.ts, so seven lines cross it; the diagnostics themselves live in quotaSkipDiagnostics.ts, under cap. Frozen rather than split: the natural next extraction is the attempt-loop body, which #12746/#12811 just moved and should settle before being cut again.",
"_rebaseline_2026_09_08_13033_responses_websearch_sse": "Own growth after rebase onto v3.8.51 tip af49d4972: open-sse/handlers/chatCore.ts 6036->6035 (-1, check-file-size split-newline). Branch stamps clientRequestedResponsesStream before web_search fallback forces stream:false, then wraps JSON via synthesizeOpenAiSseFromJson. Call-site wiring next to the existing web_search non-stream fallback; no new god-file. Covered tests/unit/responses-websearch-sse-13033.test.ts.",
"_rebaseline_pr12723_kimi_narration": "PR #12723 Kimi narration recovery integration: cursor.ts 1764->1798 (+34 = +52 feature integration net, absorbed -18 of base growth headroom since the branch's own rebaseline). Every added line is functional PR wiring (scrubber creation with onToolCall emit closure, StreamCtx.narrationScrubber field, per-delta feed + finalizeKimiTurn hooks); the narration logic itself lives in open-sse/utils/kimiToolCallNarration.ts (593 lines). Covered by tests/unit/kimi-tool-call-narration.test.ts.",
"_rebaseline_2026_09_17_merge_wave_growth": "Crescimento da leva de merges de 2026-09-17 (lote /merge-batch + uplift). Cada teto foi atribuido a sua causa antes de subir, nao rebaselinado em bloco: src/sse/handlers/chatHelpers.ts 1245->1246 (#13551, combo scope no guard fail-closed do proxy); open-sse/handlers/chatCore.ts 6203->6219 (#12905 DSML/preamble, #13910 classificacao de 2xx disfarcado, #12904 injecao unica do system prompt pos-traducao); open-sse/utils/stream.ts 3123->3140 (#12905 e #12906 retry de empty_response 502 + timeout reasoning-aware); tests/integration/chat-pipeline.test.ts 1736->1740 (#13419, as duas assercoes de header exatas atualizadas para o charset=utf-8). Todos cobertos pelos testes das proprias PRs, verificados verdes no tip apos o merge."
"_rebaseline_2026_09_17_merge_wave_growth": "Crescimento da leva de merges de 2026-09-17 (lote /merge-batch + uplift). Cada teto foi atribuido a sua causa antes de subir, nao rebaselinado em bloco: src/sse/handlers/chatHelpers.ts 1245->1246 (#13551, combo scope no guard fail-closed do proxy); open-sse/handlers/chatCore.ts 6203->6219 (#12905 DSML/preamble, #13910 classificacao de 2xx disfarcado, #12904 injecao unica do system prompt pos-traducao); open-sse/utils/stream.ts 3123->3140 (#12905 e #12906 retry de empty_response 502 + timeout reasoning-aware); tests/integration/chat-pipeline.test.ts 1736->1740 (#13419, as duas assercoes de header exatas atualizadas para o charset=utf-8). Todos cobertos pelos testes das proprias PRs, verificados verdes no tip apos o merge.",
"_rebaseline_2026_09_17_11742_log_boundary_hardening": "PR #11742 (rebase para release/v3.8.51): open-sse/handlers/chatCore.ts 6219->6287. O crescimento e a unica parte da PR que sobreviveu ao tip: endurecimento da fronteira de LOG (mais amplo que a Hard Rule #12, que cobre respostas). Sao 136 linhas adicionadas, das quais ~40 sao chamadas diretas de sanitizacao — sanitizeErrorMessage em erro de plugin (onError), em timeout de semaforo e na failureMessage antes de ela chegar ao console.log e ao call-log; sanitizeUpstreamDetails no log de resposta malformada; getSafeErrorMetadata + try/catch nos pontos onde metadata hostil (Proxy) podia lançar. O resto da PR foi descartado por ja estar no tip (#12506/#12945/#13635 error boundaries, #12429 wreq-js, #11754 aposentadoria do ChatGPT Web) — open-sse/utils/ difere do tip por UMA linha (registro do identificador publico lmarena_stream_error)."
}
6 changes: 3 additions & 3 deletions docs/diagrams/privacy-local.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions open-sse/executors/chatgptWebTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export async function buildToolModeResponse(
stream: boolean,
meta: { cid: string; created: number; model: string; idSeed?: string }
): Promise<Response> {
if (!bufferedJson.ok) return bufferedJson;
const jsonResponse = await applyToolCallsToJsonResponse(
bufferedJson,
requestedTools,
Expand Down
30 changes: 28 additions & 2 deletions open-sse/executors/claude-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const CLAUDE_WEB_API_BASE = "https://claude.ai/api";
const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`;
const CLAUDE_SESSION_COOKIE_NAME = "sessionKey";
const MAX_ERROR_BODY_BYTES = 64 * 1024;
const MAX_FORWARDED_RETRY_AFTER_SECONDS = 24 * 60 * 60;
const HTTP_DATE_PATTERN =
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT.userAgent;

type SendClaudeWebTransport = (
Expand Down Expand Up @@ -291,7 +294,9 @@ async function readTransportErrorText(result: ClaudeWebTransportResult): Promise
output += decoder.decode();
return output;
} finally {
await reader.cancel().catch(() => {});
await reader.cancel().catch(() => {
// The bounded error body is already captured; cancel can race an upstream close.
});
try {
reader.releaseLock();
} catch {
Expand All @@ -300,6 +305,26 @@ async function readTransportErrorText(result: ClaudeWebTransportResult): Promise
}
}

function normalizeForwardedRetryAfter(value: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;

if (/^\d{1,9}$/.test(trimmed)) {
const seconds = Number(trimmed);
return Number.isSafeInteger(seconds) && seconds <= MAX_FORWARDED_RETRY_AFTER_SECONDS
? String(seconds)
: null;
}

if (!HTTP_DATE_PATTERN.test(trimmed)) return null;
const timestamp = Date.parse(trimmed);
if (!Number.isFinite(timestamp)) return null;
const normalized = new Date(timestamp).toUTCString();
if (normalized !== trimmed) return null;
if (Math.abs(timestamp - Date.now()) > MAX_FORWARDED_RETRY_AFTER_SECONDS * 1000) return null;
return normalized;
}

async function errorResponseForTransport(
result: ClaudeWebTransportResult,
turn: PreparedClaudeWebTurn
Expand All @@ -311,7 +336,7 @@ async function errorResponseForTransport(
}
if (result.status === 429) {
const extraHeaders: Record<string, string> = {};
const upstreamRetryAfter = result.headers.get("retry-after");
const upstreamRetryAfter = normalizeForwardedRetryAfter(result.headers.get("retry-after"));
if (upstreamRetryAfter) {
extraHeaders["Retry-After"] = upstreamRetryAfter;
}
Expand Down Expand Up @@ -349,6 +374,7 @@ export class ClaudeWebExecutor extends BaseExecutor {
const cookieHeader = normalizeClaudeSessionCookie(rawCookie);
return verifyCookieValidity(cookieHeader, readClaudeWebDeviceId(credentials), signal);
} catch {
// Connection checks deliberately collapse malformed credentials and transport failures to false.
return false;
}
}
Expand Down
36 changes: 28 additions & 8 deletions open-sse/executors/grok-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ import {
const GROK_CHAT_API = "https://grok.com/rest/app-chat/conversations/new";
const GROK_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const GROK_PUBLIC_UPSTREAM_ERROR = "Grok upstream error";

function sanitizeGrokUpstreamError(message: unknown): string {
const sanitized = sanitizeErrorMessage(message);
return sanitized.trim() && !/^(?:[A-Za-z_$][\w$]*)?Error:\s*$/.test(sanitized)
? sanitized
: GROK_PUBLIC_UPSTREAM_ERROR;
}

function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError {
try {
return error instanceof TlsClientUnavailableError;
} catch {
// A rejected Proxy may throw while instanceof walks its prototype chain.
return false;
}
}

// ─── Model mappings ─────────────────────────────────────────────────────────
// Grok Web exposes UI modes, not stable public model IDs. Keep OmniRoute model
Expand Down Expand Up @@ -637,7 +654,11 @@ async function buildNonStreamingResponse(
if (chunk.error) {
return new Response(
JSON.stringify({
error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" },
error: {
message: sanitizeGrokUpstreamError(chunk.error),
type: "upstream_error",
code: "GROK_ERROR",
},
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
Expand Down Expand Up @@ -1030,12 +1051,13 @@ export class GrokWebExecutor extends BaseExecutor {
streamEofSymbol: "[DONE]",
});
} catch (err) {
if (err instanceof TlsClientUnavailableError) {
log?.error?.("GROK-WEB", `TLS client unavailable: ${err.message}`);
const publicError = sanitizeGrokUpstreamError(err);
if (isTlsClientUnavailableError(err)) {
log?.error?.("GROK-WEB", `TLS client unavailable: ${publicError}`);
const errResp = new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(`Grok TLS client unavailable: ${err.message}`),
message: `Grok TLS client unavailable: ${publicError}`,
type: "upstream_error",
code: "TLS_CLIENT_UNAVAILABLE",
},
Expand All @@ -1044,13 +1066,11 @@ export class GrokWebExecutor extends BaseExecutor {
);
return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload };
}
log?.error?.("GROK-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`);
log?.error?.("GROK-WEB", `Fetch failed: ${publicError}`);
const errResp = new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(
`Grok connection failed: ${err instanceof Error ? err.message : String(err)}`
),
message: `Grok connection failed: ${publicError}`,
type: "upstream_error",
},
}),
Expand Down
22 changes: 16 additions & 6 deletions open-sse/executors/lmarena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { v7 as uuidv7 } from "uuid";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { tlsFetchLMArena, TlsClientUnavailableError } from "../services/lmarenaTlsClient.ts";
import { readLMArenaCookie, reconstructLMArenaCookie } from "./lmarena/cookie.ts";
import { sanitizeLMArenaError } from "./lmarena/error.ts";
import {
LMARENA_STREAM_URL,
LMARENA_USER_AGENT,
Expand Down Expand Up @@ -50,6 +51,15 @@ interface OpenAIMessage {
content?: unknown;
}

function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError {
try {
return error instanceof TlsClientUnavailableError;
} catch {
// A rejected Proxy may throw while instanceof walks its prototype chain.
return false;
}
}

/** Optional browser-issued reCAPTCHA v3 token (operator-supplied). */
function readRecaptchaToken(credentials: unknown, body: unknown): string | null {
const fromObj = (v: unknown): string | null => {
Expand Down Expand Up @@ -146,13 +156,13 @@ export class LMArenaExecutor extends BaseExecutor {
log,
});
} catch (error) {
if (error instanceof TlsClientUnavailableError) {
log?.error?.("LMArenaExecutor", `TLS client unavailable: ${error.message}`);
return mapTlsUnavailable(error, url, headers, transformedBody);
if (isTlsClientUnavailableError(error)) {
log?.error?.("LMArenaExecutor", `TLS client unavailable: ${sanitizeLMArenaError(error)}`);
return mapTlsUnavailable(url, headers, transformedBody);
}
const message = error instanceof Error ? error.message : String(error);
log?.error?.("LMArenaExecutor", `Request failed: ${message}`);
return mapNetworkError(message, url, headers, transformedBody);
const logMessage = sanitizeLMArenaError(error);
log?.error?.("LMArenaExecutor", `Request failed: ${logMessage}`);
return mapNetworkError(url, headers, transformedBody);
}
}

Expand Down
17 changes: 17 additions & 0 deletions open-sse/executors/lmarena/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { sanitizeErrorMessage } from "../../utils/error.ts";

const ERROR_NAME_ONLY_RE = /^[A-Za-z]*Error:?$/;

/** Convert an unknown Arena failure into a stable, public-safe message. */
export function sanitizeLMArenaError(value: unknown, fallback = "Arena upstream error"): string {
let candidate = value;
try {
if (value instanceof Error) candidate = value.message;
} catch {
// Hostile thrown values can expose coercing prototype/message accessors.
}

const sanitized = sanitizeErrorMessage(candidate).trim();
if (!sanitized || ERROR_NAME_ONLY_RE.test(sanitized)) return fallback;
return sanitized;
}
5 changes: 3 additions & 2 deletions open-sse/executors/lmarena/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* LMArena live model list parsing, catalog normalization, and name→UUID resolution.
*/

import { sanitizeLMArenaError } from "./error.ts";

export const LMARENA_API_BASE = "https://arena.ai";
export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`;
/**
Expand Down Expand Up @@ -297,10 +299,9 @@ export async function resolveLMArenaModelId(model: string, log?: LogFn): Promise
if (fromSeed) return fromSeed;
return pickLMArenaModelId(requested, await getLMArenaModels(log));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log?.warn?.(
"LMArenaExecutor",
`Using raw model id after static catalog lookup failed: ${message}`
`Using raw model id after static catalog lookup failed: ${sanitizeLMArenaError(error, "Arena catalog lookup error")}`
);
return requested;
}
Expand Down
Loading
Loading