fix(executors): reconstruct LMArena split auth cookie (#4271) (#4331)

LMArena migrated to @supabase/ssr chunked auth cookies: the single
arena-auth-prod-v1 cookie is now empty and the session is split across
arena-auth-prod-v1.0, .1, … (ascending). Pasting the now-empty single
cookie sent an empty session, which upstream rejected as "invalid cookie".

reconstructLMArenaCookie() rebuilds the single cookie from its chunks
(ascending join, no decode/parse — combineChunks semantics), preserving
the rest of the pasted jar; a non-empty single cookie is forwarded
unchanged (back-compat). The credential UX now instructs pasting the full
Cookie header and tracks the .0/.1 storage keys.

Closes #4271
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 22:32:37 -03:00
committed by GitHub
parent 62e0920e5e
commit 5cca4ff90c
5 changed files with 226 additions and 6 deletions

View File

@@ -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)

View File

@@ -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=<value>`, it is
* returned unchanged (back-compat with the pre-migration single cookie).
* - Otherwise the reconstructed `arena-auth-prod-v1=<joined>` 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.<N>`).
const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`;
const chunks = new Map<number, string>();
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<string, unknown>;
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<string, unknown>).cookie;
if (typeof nested === "string" && nested.trim()) return nested;
if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested);
}
return "";
}

View File

@@ -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: {

View File

@@ -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<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;

View File

@@ -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}`
);
});
});