Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
88 changes: 85 additions & 3 deletions open-sse/executors/lmarena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/constants/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
17 changes: 14 additions & 3 deletions src/shared/providers/webSessionCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
acceptsFullCookieHeader: true,
storageKeys: ["cookie", "arena-auth-prod-v1", "session"],
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",
"arena-auth-prod-v1.0",
"arena-auth-prod-v1.1",
"session",
],
},
} satisfies Record<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;

Expand Down
126 changes: 126 additions & 0 deletions tests/unit/lmarena-split-cookie-4271.test.ts
Original file line number Diff line number Diff line change
@@ -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}`
);
});
});
Loading