fix(claude-web): unify Turnstile/executor/fast-path User-Agents behind one fingerprint (#7548) (#7711)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-19 02:31:26 -03:00
committed by GitHub
parent b4ee34fa02
commit a9eb25b93c
7 changed files with 146 additions and 13 deletions

View File

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

View File

@@ -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";

View File

@@ -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<string, string> {
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",

View File

@@ -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<string, string> = {
"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",
};

View File

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

View File

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

View File

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