mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
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:
committed by
GitHub
parent
62e0920e5e
commit
5cca4ff90c
@@ -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)
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
126
tests/unit/lmarena-split-cookie-4271.test.ts
Normal file
126
tests/unit/lmarena-split-cookie-4271.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user