mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(claude-web): unify Turnstile/executor/fast-path User-Agents behind one fingerprint (#7548) (#7711)
This commit is contained in:
committed by
GitHub
parent
b4ee34fa02
commit
a9eb25b93c
1
changelog.d/fixes/7548-claude-web-ua.md
Normal file
1
changelog.d/fixes/7548-claude-web-ua.md
Normal 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)
|
||||
30
open-sse/config/claudeWebFingerprint.ts
Normal file
30
open-sse/config/claudeWebFingerprint.ts
Normal 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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
29
open-sse/services/httpBackedChatFingerprint.ts
Normal file
29
open-sse/services/httpBackedChatFingerprint.ts
Normal 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;
|
||||
}
|
||||
70
tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts
Normal file
70
tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts
Normal 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"');
|
||||
});
|
||||
Reference in New Issue
Block a user