diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8b0973da..63361572e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ _In development — bullets added per PR; finalized at release._ ### 🐛 Fixed +- **fix(executors): ArenaLLM accepts LMArena's split Supabase SSR auth cookie** — LMArena migrated to `@supabase/ssr` chunked auth cookies: the single `arena-auth-prod-v1` cookie is now empty and the real session is split across `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). A user who pasted the (now-empty) single cookie therefore sent an empty session and upstream rejected it as "invalid cookie". The LMArena executor now reconstructs the single cookie from its chunks — reading `.0`, `.1`, … in ascending numeric order until one is missing and concatenating their raw values (`@supabase/ssr`'s `combineChunks` rule: plain `join("")`, no base64-decode, no JSON-parse, the `base64-` prefix kept verbatim) — while preserving the rest of the pasted jar. A non-empty single cookie is still forwarded unchanged (back-compat). The credential UX now instructs pasting the **full Cookie header** and tracks the `.0`/`.1` storage keys. ([#4271](https://github.com/diegosouzapw/OmniRoute/issues/4271) — thanks @caussao) - **fix(compression): preserve the cacheable prefix for automatic-cache providers** — OpenAI / Codex (and Azure-OpenAI) use _automatic_ prefix caching: the upstream caches the longest matching prefix of a request (system prompt + earliest messages) **without** any explicit `cache_control` markers in the body. The cache-aware compression guard only protected that prefix when the request carried explicit `cache_control`, so for automatic-cache providers the guard was skipped — and with compression enabled and `preserveSystemPrompt: false` (or a prefix-compressing mode like `aggressive`/`ultra`) it rewrote the system prompt / earliest messages, guaranteeing a cache miss and **higher** token spend through OmniRoute than going direct. The guard now treats a caching provider as sufficient on its own (`isCachingProvider` alone, independent of `cache_control`) to skip the system prompt and downgrade prefix-compressing modes, and OpenAI/Codex/Azure are now recognized as caching providers. Compression is still off by default — this only affects operators who enabled it with prefix preservation turned off. ([#3955](https://github.com/diegosouzapw/OmniRoute/issues/3955)) - **fix(executors): DuckDuckGo AI Chat uses duckduckgo.com (fixes 400)** — the DuckDuckGo AI Chat executor fetched status/chat and set `Origin`/`Referer` against `https://duck.ai` while still sending `Sec-Fetch-Site: same-origin`, so the request's same-origin triplet (host + Origin + Referer) was inconsistent and the backend rejected it with HTTP 400. All current DDG reverse-engineering references — and the provider registry's own `baseUrl` — use `https://duckduckgo.com`; the executor now uses it consistently for the status URL, chat URL, `Origin`, and `Referer` (the same-origin header is now coherent). The `x-fe-version` scrape regex also required a 40-hex tail but the real served token has a 20-hex tail (e.g. `serp_20250401_100419_ET-19d438eb199b2bf7c300`), so it silently fell back to a hardcoded default; the pattern is relaxed to a bounded `{20,40}` tail (still ReDoS-safe). This addresses the DuckDuckGo half of the report; the separate Chipotle/`chipotle` upstream breakage is tracked independently. ([#4037](https://github.com/diegosouzapw/OmniRoute/issues/4037) — thanks @daniij) - **fix(security): bound the prompt-injection scan to the first 16 KB (hot-path perf)** — the prompt-injection guard joined every message/system string into one buffer and ran several regexes over the **whole** thing on every chat request, with no size cap — so a 300 KB body (pasted code, RAG context) meant O(body) CPU scanning on the hot path, a self-inflicted latency/GC source under concurrency. Both detection call sites (`detectInjection` in `inputSanitizer.ts` and the custom-pattern scan in `promptInjection.ts`) now slice the joined text to the first **16 KB** (`MAX_INJECTION_SCAN_BYTES`) before the regex loop. Injection directives sit near the top of a prompt, so the generous cap preserves real detection while scanning only a bounded prefix; the existing 10 MB body-size cap (which protects ingestion) is unchanged. ([#3932](https://github.com/diegosouzapw/OmniRoute/issues/3932) — thanks @KooshaPari) diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 01f2a58a50..ca24cf3cff 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -33,17 +33,99 @@ const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream`; const LMARENA_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; +const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; + +interface ParsedCookie { + name: string; + value: string; +} + +/** + * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an + * ordered list of name/value pairs. Whitespace around names is trimmed; values + * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). + */ +function parseCookieBlob(blob: string): ParsedCookie[] { + const pairs: ParsedCookie[] = []; + for (const part of blob.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const name = part.slice(0, eq).trim(); + if (!name) continue; + const value = part.slice(eq + 1).trim(); + pairs.push({ name, value }); + } + return pairs; +} + +/** + * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the + * Supabase SSR chunked form. + * + * LMArena migrated to `@supabase/ssr`, which splits a large auth cookie across + * `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). The single + * `arena-auth-prod-v1` cookie is then left empty. Following `@supabase/ssr`'s + * `combineChunks`, we read chunks in ascending numeric order until one is + * missing and `join("")` their raw values — NO base64-decode, NO JSON-parse. + * The joined value typically starts with the literal `base64-` prefix; we keep + * it verbatim (the upstream expects it). + * + * - If the blob already carries a non-empty `arena-auth-prod-v1=`, it is + * returned unchanged (back-compat with the pre-migration single cookie). + * - Otherwise the reconstructed `arena-auth-prod-v1=` is injected while + * every other cookie in the pasted jar is preserved. + * - If neither the single cookie nor any `.N` chunk has a value, the blob is + * returned as-is so the existing missing-cookie path still fires. + */ +export function reconstructLMArenaCookie(rawCookie: string): string { + if (!rawCookie || !rawCookie.trim()) return rawCookie; + + const pairs = parseCookieBlob(rawCookie); + + // Back-compat: a non-empty single cookie is already usable — forward verbatim. + const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); + if (existing && existing.value) return rawCookie; + + // Collect chunk values keyed by their numeric index (`arena-auth-prod-v1.`). + const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; + const chunks = new Map(); + for (const { name, value } of pairs) { + if (!name.startsWith(chunkPrefix)) continue; + const idxRaw = name.slice(chunkPrefix.length); + if (!/^\d+$/.test(idxRaw)) continue; + chunks.set(Number(idxRaw), value); + } + + // Join in ascending order until a chunk is missing (combineChunks semantics). + const joinedParts: string[] = []; + for (let i = 0; chunks.has(i); i++) { + joinedParts.push(chunks.get(i) ?? ""); + } + const joined = joinedParts.join(""); + + // No usable session anywhere → return as-is so the missing-cookie path fires. + if (!joined) return rawCookie; + + // Inject the reconstructed single cookie while preserving the rest of the jar + // (drop the empty base cookie and the now-redundant chunks). + const preserved = pairs.filter( + (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) + ); + const rebuilt = [`${LMARENA_AUTH_COOKIE}=${joined}`, ...preserved.map((p) => `${p.name}=${p.value}`)]; + return rebuilt.join("; "); +} + function readLMArenaCookie(credentials: unknown): string { if (!credentials || typeof credentials !== "object") return ""; const c = credentials as Record; const direct = typeof c.cookie === "string" ? c.cookie : ""; - if (direct.trim()) return direct; + if (direct.trim()) return reconstructLMArenaCookie(direct); const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; - if (apiKey.trim()) return apiKey; + if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); const psd = c.providerSpecificData; if (psd && typeof psd === "object") { const nested = (psd as Record).cookie; - if (typeof nested === "string" && nested.trim()) return nested; + if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); } return ""; } diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 3e8825beea..7d484f4e4b 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -501,7 +501,7 @@ export const WEB_COOKIE_PROVIDERS = { freeNote: "Free model comparison platform — 40+ models (GPT, Claude, Gemini, Llama). No subscription required.", authHint: - "Paste your session cookie from lmarena.ai (DevTools → Application → Cookies). Optional — works with free tier for basic comparisons.", + "Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons.", riskNoticeVariant: "webCookie", }, huggingchat: { diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index cf5e403367..772fe50f3a 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -204,10 +204,21 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { kind: "cookie", // lmarena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`, // which never matched the real cookie name and confused users). #3810 + // + // #4271: LMArena migrated to Supabase SSR chunked cookies — the single + // `arena-auth-prod-v1` cookie is now empty and the session is split across + // `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … Users must paste the FULL + // Cookie header so the executor can reconstruct the single cookie from chunks. credentialName: "arena-auth-prod-v1", - placeholder: "arena-auth-prod-v1=... or full Cookie header from lmarena.ai", + placeholder: "Paste the full Cookie header from lmarena.ai (the session is now split across arena-auth-prod-v1.0, .1, …)", acceptsFullCookieHeader: true, - storageKeys: ["cookie", "arena-auth-prod-v1", "session"], + storageKeys: [ + "cookie", + "arena-auth-prod-v1", + "arena-auth-prod-v1.0", + "arena-auth-prod-v1.1", + "session", + ], }, } satisfies Record; diff --git a/tests/unit/lmarena-split-cookie-4271.test.ts b/tests/unit/lmarena-split-cookie-4271.test.ts new file mode 100644 index 0000000000..80b3049282 --- /dev/null +++ b/tests/unit/lmarena-split-cookie-4271.test.ts @@ -0,0 +1,126 @@ +/** + * LMArena Split Supabase SSR Cookie — Regression Tests (issue #4271) + * + * LMArena migrated to Supabase SSR chunked auth cookies. The single + * `arena-auth-prod-v1` cookie is now empty; the real session value is split + * across `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). We must + * reconstruct the single cookie from its chunks (plain `values.join("")`, the + * `@supabase/ssr` `combineChunks` rule — NO base64-decode, NO JSON-parse) before + * forwarding the Cookie header upstream. + * + * Run: + * npx cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx \ + * --import ./open-sse/utils/setupPolyfill.ts \ + * --import ./tests/_setup/isolateDataDir.ts \ + * --test --test-force-exit tests/unit/lmarena-split-cookie-4271.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + LMArenaExecutor, + reconstructLMArenaCookie, +} from "../../open-sse/executors/lmarena.ts"; +import { getWebSessionCredentialRequirement } from "../../src/shared/providers/webSessionCredentials.ts"; + +function cookieHeaderFor(credentials: unknown): string | undefined { + const executor = new LMArenaExecutor(); + const headers = (executor as any).buildHeaders("gpt-4", credentials, {}); + return headers.Cookie; +} + +describe("LMArena split Supabase SSR cookie (#4271)", () => { + it("reconstructs the single cookie from ascending chunks (no decode)", () => { + // The single base cookie is empty; the session lives in .0 + .1 + const raw = + "arena-auth-prod-v1=; arena-auth-prod-v1.0=base64-eyJABC; arena-auth-prod-v1.1=DEF.ghi"; + const reconstructed = reconstructLMArenaCookie(raw); + + // Ascending concat of the chunk values, used verbatim (base64- prefix kept). + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `expected reconstructed cookie to carry the joined session, got: ${reconstructed}` + ); + + // And it must flow through to the forwarded Cookie header. + const header = cookieHeaderFor({ cookie: raw }); + assert.ok(header, "should set a Cookie header"); + assert.ok( + header!.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `Cookie header should carry the reconstructed session, got: ${header}` + ); + }); + + it("leaves a non-empty single cookie unchanged (back-compat)", () => { + const raw = "arena-auth-prod-v1=base64-xyz"; + const reconstructed = reconstructLMArenaCookie(raw); + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-xyz"), + `back-compat cookie should be preserved, got: ${reconstructed}` + ); + + const header = cookieHeaderFor({ cookie: raw }); + assert.equal(header, "arena-auth-prod-v1=base64-xyz"); + }); + + it("concatenates chunks in ascending numeric order even when pasted out of order", () => { + const raw = + "arena-auth-prod-v1.1=DEF.ghi; arena-auth-prod-v1.0=base64-eyJABC; arena-auth-prod-v1="; + const reconstructed = reconstructLMArenaCookie(raw); + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `expected .0 then .1 regardless of paste order, got: ${reconstructed}` + ); + }); + + it("preserves other cookies in the jar while injecting the reconstructed session", () => { + const raw = + "cf_clearance=abc; arena-auth-prod-v1=; arena-auth-prod-v1.0=base64-eyJABC; arena-auth-prod-v1.1=DEF.ghi; sidebar=open"; + const reconstructed = reconstructLMArenaCookie(raw); + assert.ok( + reconstructed.includes("arena-auth-prod-v1=base64-eyJABCDEF.ghi"), + `session should be reconstructed, got: ${reconstructed}` + ); + assert.ok(reconstructed.includes("cf_clearance=abc"), "should keep cf_clearance"); + assert.ok(reconstructed.includes("sidebar=open"), "should keep sidebar"); + }); + + it("treats an empty base with no chunks as no usable session (returned as-is)", () => { + const raw = "arena-auth-prod-v1="; + const reconstructed = reconstructLMArenaCookie(raw); + // No usable value to inject — return as-is so the existing missing-cookie path fires. + assert.equal(reconstructed, raw); + + const header = cookieHeaderFor({ cookie: raw }); + // The empty base cookie is still forwarded verbatim (non-empty string), but it + // carries no session value. + assert.ok( + !/arena-auth-prod-v1=[^;\s]/.test(header ?? ""), + `should not fabricate a session value, got: ${header}` + ); + }); +}); + +describe("LMArena split-cookie credential storage keys (#4271)", () => { + it("knows about the chunked .0 / .1 storage keys", () => { + const req = getWebSessionCredentialRequirement("lmarena"); + assert.ok(req, "should have a credential requirement"); + assert.ok( + req!.storageKeys.includes("arena-auth-prod-v1.0"), + "storageKeys should include arena-auth-prod-v1.0" + ); + assert.ok( + req!.storageKeys.includes("arena-auth-prod-v1.1"), + "storageKeys should include arena-auth-prod-v1.1" + ); + }); + + it("instructs pasting the full Cookie header in the placeholder", () => { + const req = getWebSessionCredentialRequirement("lmarena"); + assert.ok(req, "should have a credential requirement"); + assert.ok( + /full cookie header/i.test(req!.placeholder), + `placeholder should instruct pasting the full Cookie header, got: ${req!.placeholder}` + ); + }); +});