mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 14:52:22 +03:00
Compare commits
2 Commits
fix/13232-
...
fix/13679a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
154a96af04 | ||
|
|
58abfb4d57 |
@@ -1 +0,0 @@
|
||||
- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) — thanks @oleksandr1811
|
||||
1
changelog.d/fixes/13679-cloudsync-hmac-fail-open.md
Normal file
1
changelog.d/fixes/13679-cloudsync-hmac-fail-open.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(auth):** `verifyCloudSignature()` no longer accepts an unverifiable `X-Cloud-Sig` when `OMNIROUTE_CLOUD_SYNC_SECRET` is unset — a forged/garbage signature is rejected outright, and the new opt-in `OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true` flag rejects unsigned Cloud-sync payloads too (default stays legacy pass-through for v3.8.x; the default flips in v3.9) ([#13679](https://github.com/diegosouzapw/OmniRoute/issues/13679))
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Shared classification for browser-backed executors: distinguishes a missing Playwright
|
||||
* Chromium binary (`chromium.launch: Executable doesn't exist at ...`) from a transient upstream
|
||||
* fault. This is a host/config problem, not something a retry loop can fix, so executors must
|
||||
* NOT surface it as a plain retryable 5xx (which marks the account unavailable / trips the
|
||||
* provider circuit breaker). Originally added for `gemini-web.ts` (#3516); extracted here so
|
||||
* every browser-backed executor (Gemini Web, Z.ai Web, ...) can share the same detection.
|
||||
*/
|
||||
export function isMissingBrowserExecutable(message: string): boolean {
|
||||
if (!message) return false;
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("executable doesn't exist") ||
|
||||
lower.includes("executablenotfound") ||
|
||||
lower.includes("playwright install") ||
|
||||
(lower.includes("chromium") && lower.includes("download"))
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
import { normalizeGeminiCookieInput } from "../utils/geminiCookies.ts";
|
||||
import { prepareToolMessages } from "../translator/webTools.ts";
|
||||
import { buildToolModeResponse } from "./chatgptWebTools.ts";
|
||||
@@ -28,12 +27,22 @@ import {
|
||||
|
||||
const GEMINI_URL = "https://gemini.google.com/app";
|
||||
|
||||
// Re-exported for backward compatibility: some tests/callers import this classification helper
|
||||
// from gemini-web.ts, its original home (#3516). The implementation now lives in
|
||||
// browserExecutableCheck.ts so other browser-backed executors (e.g. zai-web.ts, #13232) can
|
||||
// share it without importing this whole executor module.
|
||||
export { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
|
||||
/**
|
||||
* Whether an error came from Playwright failing to launch because the browser binary is not
|
||||
* installed (`chromium.launch: Executable doesn't exist at ...`). This is a host/config
|
||||
* problem, not a transient upstream fault, so the executor must NOT surface it as a retryable
|
||||
* 500 (which marks the account unavailable and loops / trips the provider breaker). See #3516.
|
||||
*/
|
||||
export function isMissingBrowserExecutable(message: string): boolean {
|
||||
if (!message) return false;
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("executable doesn't exist") ||
|
||||
lower.includes("executablenotfound") ||
|
||||
lower.includes("playwright install") ||
|
||||
(lower.includes("chromium") && lower.includes("download"))
|
||||
);
|
||||
}
|
||||
const GEMINI_USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
makeZaiChunkEmitter,
|
||||
} from "./zai-web/stream.ts";
|
||||
import { browserBackedChat } from "../services/browserBackedChat.ts";
|
||||
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts";
|
||||
import {
|
||||
makeExecutorErrorResult as makeErrorResult,
|
||||
@@ -425,26 +424,9 @@ export class ZaiWebExecutor extends BaseExecutor {
|
||||
try {
|
||||
result = await browserBackedChat(buildZaiBrowserChatOptions({ ...input, attachments }));
|
||||
} catch (error) {
|
||||
const rawMessage = error instanceof Error ? error.message : "browser transport unavailable";
|
||||
// #13232: a missing Playwright browser binary is a host/config problem, not a transient
|
||||
// upstream fault (same class as #3516 in gemini-web.ts). Surface an actionable message and
|
||||
// tag it with the connection-cooldown hint so accountFallback skips the whole-provider
|
||||
// circuit breaker (502/500 would trip it) and applies a short, non-exponential cooldown
|
||||
// instead.
|
||||
if (isMissingBrowserExecutable(rawMessage)) {
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
503,
|
||||
"Z.ai requires the Playwright Chromium browser, which is not installed. " +
|
||||
"Run `npx playwright install chromium` on the host (or rebuild the Docker image " +
|
||||
"with browsers).",
|
||||
input.body,
|
||||
ZAI_CHAT_URL,
|
||||
{ "X-Omni-Fallback-Hint": "connection_cooldown" }
|
||||
),
|
||||
};
|
||||
}
|
||||
const message = sanitizeErrorMessage(rawMessage);
|
||||
const message = sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "browser transport unavailable"
|
||||
);
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
502,
|
||||
|
||||
@@ -1134,8 +1134,7 @@ export function makeExecutorErrorResult(
|
||||
status: number,
|
||||
message: string,
|
||||
body: unknown,
|
||||
url: string,
|
||||
extraResponseHeaders?: Record<string, string>
|
||||
url: string
|
||||
) {
|
||||
return {
|
||||
response: new Response(
|
||||
@@ -1146,10 +1145,7 @@ export function makeExecutorErrorResult(
|
||||
code: `HTTP_${status}`,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...extraResponseHeaders },
|
||||
}
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url,
|
||||
headers: {} as Record<string, string>,
|
||||
|
||||
@@ -13,6 +13,14 @@ const CLOUD_SYNC_SECRET = process.env.OMNIROUTE_CLOUD_SYNC_SECRET || "";
|
||||
// hostile CLOUD_URL cannot silently swap user OAuth tokens.
|
||||
const CLOUD_SYNC_SECRETS_ENABLED = process.env.OMNIROUTE_CLOUD_SYNC_SECRETS === "true";
|
||||
|
||||
// #13679 PR A — opt-in early enforcement of the "no secret configured" branch
|
||||
// below. Bringing the v3.9 enforce-by-default switch forward as an explicit
|
||||
// opt-out-safe flag: default OFF preserves v3.8.x back-compat for peers that
|
||||
// haven't rotated in a shared secret yet (an unsigned payload still passes).
|
||||
// Set to "true" to reject even an unsigned payload when no local secret is
|
||||
// configured — the default flips to enforced in v3.9.
|
||||
const CLOUD_SYNC_ENFORCE_SIGNATURE = process.env.OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE === "true";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
@@ -40,18 +48,38 @@ function toDateMs(value: unknown): number {
|
||||
// 2. We verify the signature with `crypto.timingSafeEqual` before parsing the
|
||||
// JSON, so a MITM on the CLOUD_URL channel — or a misconfigured CLOUD_URL
|
||||
// pointing at an attacker — cannot inject providers/tokens.
|
||||
// If `OMNIROUTE_CLOUD_SYNC_SECRET` is unset, signature validation is logged but
|
||||
// not enforced (back-compat for users on v3.8.x who haven't issued a shared
|
||||
// secret yet). The enforce-by-default switch will flip in v3.9.
|
||||
// If `OMNIROUTE_CLOUD_SYNC_SECRET` is unset, a PRESENT signature is always
|
||||
// rejected (#13679 PR A — we have no key to check it against, so a signature
|
||||
// we cannot verify is treated as invalid rather than blindly trusted) and an
|
||||
// ABSENT signature falls through in legacy unverified mode by default
|
||||
// (back-compat for users on v3.8.x who haven't issued a shared secret yet;
|
||||
// opt in early via `OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true`). The
|
||||
// enforce-by-default switch for the absent-signature case will flip in v3.9.
|
||||
export function verifyCloudSignature(rawBody: string, sigHeader: string | null): boolean {
|
||||
if (!CLOUD_SYNC_SECRET) {
|
||||
if (sigHeader) {
|
||||
// We can't verify, but the server is at least trying. Pass through.
|
||||
return true;
|
||||
// We have no secret to verify against, so a signature we can't check is
|
||||
// treated as invalid rather than passed through (#13679 PR A item (b) —
|
||||
// closes the "forge any X-Cloud-Sig and it's accepted" fail-open case).
|
||||
console.warn(
|
||||
"[cloudSync] OMNIROUTE_CLOUD_SYNC_SECRET is not set but the Cloud response carries an " +
|
||||
"X-Cloud-Sig header — rejecting an unverifiable signature. Set the secret to enable " +
|
||||
"verification."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (CLOUD_SYNC_ENFORCE_SIGNATURE) {
|
||||
console.warn(
|
||||
"[cloudSync] OMNIROUTE_CLOUD_SYNC_SECRET is not set and the Cloud response carries no " +
|
||||
"X-Cloud-Sig, and OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true — rejecting unsigned payload."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
console.warn(
|
||||
"[cloudSync] OMNIROUTE_CLOUD_SYNC_SECRET is not set and the Cloud response carries no X-Cloud-Sig. " +
|
||||
"Token sync runs in legacy unverified mode — set the secret to enforce HMAC verification."
|
||||
"Token sync runs in legacy unverified mode — set the secret (or " +
|
||||
"OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true) to enforce HMAC verification. This legacy " +
|
||||
"pass-through default will flip to enforced in v3.9."
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -207,4 +235,9 @@ async function updateLocalTokens(cloudProviders: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export { CLOUD_URL, CLOUD_SYNC_TIMEOUT_MS, CLOUD_SYNC_SECRETS_ENABLED };
|
||||
export {
|
||||
CLOUD_URL,
|
||||
CLOUD_SYNC_TIMEOUT_MS,
|
||||
CLOUD_SYNC_SECRETS_ENABLED,
|
||||
CLOUD_SYNC_ENFORCE_SIGNATURE,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Regression for #13679 PR A: verifyCloudSignature() must not fail open when a
|
||||
* present X-Cloud-Sig cannot be verified (no local OMNIROUTE_CLOUD_SYNC_SECRET).
|
||||
*
|
||||
* Before this fix: a garbage/forged X-Cloud-Sig header was ALWAYS accepted when
|
||||
* the local secret was unset ("we can't verify, but the server is at least
|
||||
* trying — pass through"). That let a MITM on the CLOUD_URL channel, or a
|
||||
* misconfigured/compromised CLOUD_URL, forge any signature value and have it
|
||||
* accepted — defeating the point of the signature check for any install that
|
||||
* hasn't issued a shared secret yet.
|
||||
*
|
||||
* Fix (owner decision 2026-09-15, PR A):
|
||||
* (b) unconditional: a PRESENT-but-unverifiable signature is now rejected,
|
||||
* regardless of the opt-in enforce flag below.
|
||||
* (a) opt-in only (OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true, default OFF):
|
||||
* also rejects a payload carrying NO signature at all. Default stays
|
||||
* legacy pass-through for v3.8.x peers that haven't rotated in a shared
|
||||
* secret yet — the default flips to enforced in v3.9.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.OMNIROUTE_CLOUD_SYNC_SECRET;
|
||||
const ORIGINAL_ENFORCE = process.env.OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE;
|
||||
|
||||
function restoreEnv() {
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.OMNIROUTE_CLOUD_SYNC_SECRET;
|
||||
else process.env.OMNIROUTE_CLOUD_SYNC_SECRET = ORIGINAL_SECRET;
|
||||
if (ORIGINAL_ENFORCE === undefined) delete process.env.OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE;
|
||||
else process.env.OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE = ORIGINAL_ENFORCE;
|
||||
}
|
||||
|
||||
test.after(restoreEnv);
|
||||
|
||||
test("issue #13679: a present-but-unverifiable X-Cloud-Sig is rejected even without a local secret", async () => {
|
||||
delete process.env.OMNIROUTE_CLOUD_SYNC_SECRET;
|
||||
delete process.env.OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE;
|
||||
try {
|
||||
const { verifyCloudSignature } = await import(
|
||||
`../../../src/lib/cloudSync.ts?case=13679-forged-${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const rawBody = JSON.stringify({ providers: [{ id: "evil", accessToken: "stolen" }] });
|
||||
const forgedSig = "0".repeat(64);
|
||||
|
||||
assert.equal(
|
||||
verifyCloudSignature(rawBody, forgedSig),
|
||||
false,
|
||||
"a garbage X-Cloud-Sig must be REJECTED even when the local secret is unset (fail-open closed)"
|
||||
);
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
test("issue #13679: legacy peers with NO X-Cloud-Sig header still pass by default (v3.8.x back-compat)", async () => {
|
||||
delete process.env.OMNIROUTE_CLOUD_SYNC_SECRET;
|
||||
delete process.env.OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE;
|
||||
try {
|
||||
const { verifyCloudSignature } = await import(
|
||||
`../../../src/lib/cloudSync.ts?case=13679-legacy-${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const rawBody = JSON.stringify({ providers: [] });
|
||||
|
||||
assert.equal(
|
||||
verifyCloudSignature(rawBody, null),
|
||||
true,
|
||||
"an unsigned payload from a legacy peer must still pass through by default in v3.8.x"
|
||||
);
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
test("issue #13679: OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true rejects an unsigned payload too", async () => {
|
||||
delete process.env.OMNIROUTE_CLOUD_SYNC_SECRET;
|
||||
process.env.OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE = "true";
|
||||
try {
|
||||
const { verifyCloudSignature } = await import(
|
||||
`../../../src/lib/cloudSync.ts?case=13679-enforced-${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const rawBody = JSON.stringify({ providers: [] });
|
||||
|
||||
assert.equal(
|
||||
verifyCloudSignature(rawBody, null),
|
||||
false,
|
||||
"with the opt-in enforce flag set, an unsigned payload must be rejected"
|
||||
);
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Regression for GitHub issue #13232 — "[BUG] Z.ai web error".
|
||||
*
|
||||
* The Z.ai web transport drives a real headed Chromium browser (via Playwright) to get past
|
||||
* Z.ai's CAPTCHA. When the local Playwright Chromium binary is missing,
|
||||
* `browserType.launch()` throws "Executable doesn't exist at ...". Before this fix, zai-web.ts
|
||||
* had no classification for that failure and surfaced it as a plain 502 with no fallback hint —
|
||||
* a status that trips the whole-provider circuit breaker (`AGENTS.md` → "Provider Circuit
|
||||
* Breaker") as if the upstream itself were failing, instead of applying the intended
|
||||
* host/config connection cooldown. This mirrors the exact failure class already handled for
|
||||
* Gemini Web in #3516 (`isMissingBrowserExecutable`, now shared via
|
||||
* `open-sse/executors/browserExecutableCheck.ts`).
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
const mod = await import("../../open-sse/executors/zai-web.ts");
|
||||
|
||||
const TEST_TOKEN = `e30.${Buffer.from(JSON.stringify({ id: "user-123" })).toString("base64url")}.sig`;
|
||||
|
||||
describe("issue #13232 — Z.ai browser transport classifies a missing Chromium install", () => {
|
||||
let emptyBrowsersDir: string;
|
||||
let originalBrowsersPath: string | undefined;
|
||||
|
||||
before(() => {
|
||||
emptyBrowsersDir = fs.mkdtempSync(path.join(os.tmpdir(), "playwright-empty-"));
|
||||
originalBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
// Force chromium.launch() to genuinely fail with the exact class of error the reporter hit
|
||||
// ("Executable doesn't exist at ..."), without touching any real ~/.cache/ms-playwright
|
||||
// install.
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = emptyBrowsersDir;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (originalBrowsersPath === undefined) {
|
||||
delete process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
} else {
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = originalBrowsersPath;
|
||||
}
|
||||
fs.rmSync(emptyBrowsersDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it(
|
||||
"returns a classified 503 + X-Omni-Fallback-Hint: connection_cooldown instead of a bare " +
|
||||
"502 (contrast: gemini-web.ts isMissingBrowserExecutable, #3516)",
|
||||
async () => {
|
||||
const executor = new mod.ZaiWebExecutor();
|
||||
const body = { model: "glm-5.3-flash", messages: [{ role: "user", content: "hi" }] };
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.3-flash",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: { apiKey: TEST_TOKEN },
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.ok("response" in result, "expected an error Response, not a stream result");
|
||||
const response = (result as { response: Response }).response;
|
||||
const payload = (await response.json()) as { error?: { message?: string } };
|
||||
|
||||
assert.equal(
|
||||
response.status,
|
||||
503,
|
||||
"zai-web must classify a missing local Chromium install as a host/config error (503), " +
|
||||
"not a generic retryable 502 that trips the whole-provider circuit breaker."
|
||||
);
|
||||
assert.equal(
|
||||
response.headers.get("X-Omni-Fallback-Hint"),
|
||||
"connection_cooldown",
|
||||
"the connection-cooldown hint must be set so accountFallback applies a short cooldown " +
|
||||
"instead of tripping the provider circuit breaker."
|
||||
);
|
||||
assert.match(
|
||||
payload.error?.message ?? "",
|
||||
/Playwright Chromium browser.*not installed.*npx playwright install chromium/s
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user