diff --git a/changelog.d/fixes/7548-claude-web-ua.md b/changelog.d/fixes/7548-claude-web-ua.md new file mode 100644 index 0000000000..c5f2d5f623 --- /dev/null +++ b/changelog.d/fixes/7548-claude-web-ua.md @@ -0,0 +1 @@ +- fix(claude-web): unify Turnstile solver, executor and httpBackedChat fast-path User-Agents behind one shared fingerprint module so `cf_clearance` is never solved under a different UA than the one that replays it (#7548) diff --git a/open-sse/config/claudeWebFingerprint.ts b/open-sse/config/claudeWebFingerprint.ts new file mode 100644 index 0000000000..1e8d996d8f --- /dev/null +++ b/open-sse/config/claudeWebFingerprint.ts @@ -0,0 +1,30 @@ +/** + * Claude Web — shared browser fingerprint source of truth + * + * Cloudflare binds the `cf_clearance` cookie minted by the Turnstile solver + * to the User-Agent (+ TLS/JA3 fingerprint + IP) that solved the challenge. + * If the completion request later replays that cookie under a *different* + * User-Agent, Cloudflare rejects it and the executor surfaces a persistent + * 429 (see #7548). + * + * Every part of the claude-web pipeline that talks to claude.ai — the + * Turnstile solver, the direct-fetch executor, and the httpBackedChat + * fast path — MUST derive its User-Agent / Client-Hints headers from this + * single constant so they can never drift apart again. + * + * Platform choice: Linux, matching the `chrome_146` TLS/JA3 profile used by + * `claudeTlsClient.ts` and the browser-pool default (`browserPool.ts`). + */ +export const CLAUDE_WEB_FINGERPRINT = { + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + secChUa: '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"', + secChUaPlatform: '"Linux"', +} as const; + +/** + * Bump this whenever `CLAUDE_WEB_FINGERPRINT` changes so any previously + * cached `cf_clearance` token (minted under the old fingerprint) is treated + * as stale rather than replayed under the new one. + */ +export const CLAUDE_WEB_FINGERPRINT_VERSION = "v2-linux-unified"; diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 7e26777339..b28f585078 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -23,6 +23,7 @@ import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { tlsFetchClaude } from "../services/claudeTlsClient.ts"; import { getCfClearanceToken } from "../services/claudeTurnstileSolver.ts"; +import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts"; import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; import { randomUUID } from "crypto"; import { sanitizeErrorMessage } from "../utils/error.ts"; @@ -37,8 +38,7 @@ import { const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`; -const CLAUDE_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; +const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT.userAgent; // Session cookie constants const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; @@ -108,9 +108,9 @@ function getBrowserHeaders(deviceId?: string): Record { Pragma: "no-cache", Priority: "u=1, i", Referer: "https://claude.ai/new", - "Sec-Ch-Ua": '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"', + "Sec-Ch-Ua": CLAUDE_WEB_FINGERPRINT.secChUa, "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"Linux"', + "Sec-Ch-Ua-Platform": CLAUDE_WEB_FINGERPRINT.secChUaPlatform, "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 71e63b46d3..567e22338a 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -26,6 +26,7 @@ import { } from "./browserPool.ts"; import tlsClient from "../utils/tlsClient.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { resolveHttpBackedChatFingerprint } from "./httpBackedChatFingerprint.ts"; // Safety constants const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB @@ -441,11 +442,10 @@ export async function httpBackedChat( const t0 = Date.now(); const { chatUrl, userMessage, cookieString, cookieDomain, chatUrlMatchDomain, signal } = req; - + const fingerprint = resolveHttpBackedChatFingerprint(chatUrlMatchDomain); // #7548 // Build browser-emulated headers const headers: Record = { - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + "User-Agent": fingerprint.userAgent, Accept: "text/event-stream, application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", @@ -460,9 +460,9 @@ export async function httpBackedChat( "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", - "Sec-Ch-Ua": '"Chromium";v="149", "Google Chrome";v="149", "Not-A.Brand";v="99"', + "Sec-Ch-Ua": fingerprint.secChUa, "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Ch-Ua-Platform": fingerprint.secChUaPlatform, Priority: "u=1, i", }; diff --git a/open-sse/services/claudeTurnstileSolver.ts b/open-sse/services/claudeTurnstileSolver.ts index 2f3af01fa3..a9164839a3 100644 --- a/open-sse/services/claudeTurnstileSolver.ts +++ b/open-sse/services/claudeTurnstileSolver.ts @@ -11,6 +11,10 @@ */ import type { Browser, Page } from "playwright"; +import { + CLAUDE_WEB_FINGERPRINT, + CLAUDE_WEB_FINGERPRINT_VERSION, +} from "../config/claudeWebFingerprint.ts"; const CLAUDE_WEB_URL = "https://claude.ai"; const CHALLENGE_TIMEOUT = 60000; // 60s to solve challenge @@ -85,8 +89,7 @@ export async function solveTurnstile(options?: { const { chromium } = await import("playwright"); browser = await chromium.launch({ headless }); const context = await browser.newContext({ - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + userAgent: CLAUDE_WEB_FINGERPRINT.userAgent, viewport: { width: 1280, height: 720 }, ignoreHTTPSErrors: process.env.OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS === "true", }); @@ -149,7 +152,7 @@ export async function getCfClearanceToken(options?: { force?: boolean; headless?: boolean; }): Promise { - const cacheKey = "claude-cf-clearance"; + const cacheKey = `claude-cf-clearance-${CLAUDE_WEB_FINGERPRINT_VERSION}`; const cached = tokenCache.get(cacheKey); if (cfClearanceTokenOverride) { @@ -193,7 +196,7 @@ export function getCacheStatus(): { hasCached: boolean; expiresIn?: number; } { - const cacheKey = "claude-cf-clearance"; + const cacheKey = `claude-cf-clearance-${CLAUDE_WEB_FINGERPRINT_VERSION}`; const cached = tokenCache.get(cacheKey); if (!cached) { diff --git a/open-sse/services/httpBackedChatFingerprint.ts b/open-sse/services/httpBackedChatFingerprint.ts new file mode 100644 index 0000000000..ea2cb408b3 --- /dev/null +++ b/open-sse/services/httpBackedChatFingerprint.ts @@ -0,0 +1,29 @@ +/** + * Header fingerprint resolution for `httpBackedChat()`. + * + * claude.ai MUST reuse the exact fingerprint the Turnstile solver used to + * mint `cf_clearance` (see `open-sse/config/claudeWebFingerprint.ts`) — + * otherwise Cloudflare rejects the replayed cookie and every request 429s + * (#7548). Other `httpBackedChat` callers (e.g. duckduckgo-web) keep their + * own independent fingerprint, which never needs to match a solved cookie. + */ +import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts"; + +export interface HttpBackedChatFingerprint { + userAgent: string; + secChUa: string; + secChUaPlatform: string; +} + +const DUCKDUCKGO_FALLBACK_FINGERPRINT: HttpBackedChatFingerprint = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + secChUa: '"Chromium";v="149", "Google Chrome";v="149", "Not-A.Brand";v="99"', + secChUaPlatform: '"macOS"', +}; + +export function resolveHttpBackedChatFingerprint( + chatUrlMatchDomain: string +): HttpBackedChatFingerprint { + return chatUrlMatchDomain === "claude.ai" ? CLAUDE_WEB_FINGERPRINT : DUCKDUCKGO_FALLBACK_FINGERPRINT; +} diff --git a/tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts b/tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts new file mode 100644 index 0000000000..ad732ec507 --- /dev/null +++ b/tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts @@ -0,0 +1,70 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { CLAUDE_WEB_FINGERPRINT } from "../../open-sse/config/claudeWebFingerprint.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "..", ".."); +const readSource = (rel: string) => readFileSync(join(REPO_ROOT, rel), "utf8"); + +function detectPlatform(ua: string): string { + if (/Windows/i.test(ua)) return "Windows"; + if (/Macintosh|Mac OS X/i.test(ua)) return "macOS"; + if (/X11; Linux/i.test(ua)) return "Linux"; + return "unknown"; +} + +const IMPORT_PATTERN = /import\s*\{\s*CLAUDE_WEB_FINGERPRINT[^}]*\}\s*from\s*"[^"]*config\/claudeWebFingerprint\.ts"/; + +test("claude-web: Turnstile solver derives its UA from the shared fingerprint module (#7548)", () => { + const solverSrc = readSource("open-sse/services/claudeTurnstileSolver.ts"); + + assert.ok( + IMPORT_PATTERN.test(solverSrc), + "claudeTurnstileSolver.ts must import CLAUDE_WEB_FINGERPRINT from the shared module" + ); + assert.match(solverSrc, /userAgent:\s*CLAUDE_WEB_FINGERPRINT\.userAgent/); + // No stray hardcoded UA literal left behind (that's exactly how #7548 regressed). + assert.ok( + !/Mozilla\/5\.0[^"]*"/.test(solverSrc), + "solver must not hardcode a UA literal anymore — it must come from CLAUDE_WEB_FINGERPRINT" + ); +}); + +test("claude-web: executor CLAUDE_USER_AGENT and Sec-Ch-Ua-Platform derive from the shared fingerprint (#7548)", () => { + const executorSrc = readSource("open-sse/executors/claude-web.ts"); + + assert.ok( + IMPORT_PATTERN.test(executorSrc), + "claude-web.ts must import CLAUDE_WEB_FINGERPRINT from the shared module" + ); + assert.match(executorSrc, /const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT\.userAgent;/); + assert.match(executorSrc, /"Sec-Ch-Ua-Platform":\s*CLAUDE_WEB_FINGERPRINT\.secChUaPlatform/); +}); + +test("claude-web: httpBackedChat fast path uses the shared fingerprint for the claude.ai branch (#7548)", () => { + const fastPathSrc = readSource("open-sse/services/browserBackedChat.ts"); + const resolverSrc = readSource("open-sse/services/httpBackedChatFingerprint.ts"); + + assert.match( + fastPathSrc, + /resolveHttpBackedChatFingerprint/, + "browserBackedChat.ts must resolve headers via resolveHttpBackedChatFingerprint()" + ); + assert.ok( + IMPORT_PATTERN.test(resolverSrc), + "httpBackedChatFingerprint.ts must import CLAUDE_WEB_FINGERPRINT from the shared module" + ); + assert.match(resolverSrc, /chatUrlMatchDomain === "claude\.ai" \? CLAUDE_WEB_FINGERPRINT/); +}); + +test("claude-web: solver, executor and fast-path UAs are all the same value at runtime, platform Linux (#7548)", () => { + // This is the actual regression: before the fix these three literals were + // "Windows", "Linux" and "macOS" respectively — a cf_clearance token minted + // under one UA got replayed under a different one and Cloudflare rejected + // it, surfacing as a persistent 429 on every claude-web request. + assert.equal(detectPlatform(CLAUDE_WEB_FINGERPRINT.userAgent), "Linux"); + assert.equal(CLAUDE_WEB_FINGERPRINT.secChUaPlatform, '"Linux"'); +});