From 4902ac8128126e268aec4af93c7ea9c616d20863 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Wed, 16 Sep 2026 00:54:00 -0400 Subject: [PATCH] fix(network): skip Chrome TLS impersonation for Groq (#13445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groq is now excluded from Chrome TLS impersonation in both the direct and the proxied dispatch branch, even when `TLS_FINGERPRINT_PROVIDERS` is unset or explicitly lists it — Cloudflare answers the spoofed fingerprint with 1010 `browser_signature_banned` (#13225). Scope is contained: the whole path is behind `ENABLE_TLS_FINGERPRINT`, which defaults to off, so nothing changes for operators who never opted in. Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run. Thanks @HouMinXi! --- .../fixes/13445-groq-tls-fingerprint.md | 1 + config/quality/file-size-baseline.json | 3 +- docs/reference/ENVIRONMENT.md | 2 +- open-sse/utils/proxyFetch.ts | 29 ++++- tests/unit/tls-proxy-context.test.ts | 104 ++++++++++++++++++ 5 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/13445-groq-tls-fingerprint.md diff --git a/changelog.d/fixes/13445-groq-tls-fingerprint.md b/changelog.d/fixes/13445-groq-tls-fingerprint.md new file mode 100644 index 0000000000..8fc169fedb --- /dev/null +++ b/changelog.d/fixes/13445-groq-tls-fingerprint.md @@ -0,0 +1 @@ +- **fix(network):** skip Chrome TLS impersonation for Groq (`api.groq.com`); Cloudflare 1010s that JA3 while native undici reaches the API ([#13445](https://github.com/diegosouzapw/OmniRoute/pull/13445)) (#13225) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 030599c063..1f802796a9 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_15_13445_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/utils/proxyFetch.ts->1296. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_15_13643_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/executors/codex.ts->1528. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_15_13609_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/services/accountFallback.ts->2507. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_15_13602_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1214. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", @@ -465,7 +466,7 @@ "open-sse/services/combo/executeTargetAttempt.ts": 1228, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, - "open-sse/utils/proxyFetch.ts": 1275, + "open-sse/utils/proxyFetch.ts": 1296, "open-sse/utils/stream.ts": 3098, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335, diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c4052e9748..e25c1c6ca6 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -373,7 +373,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. | | `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. | | `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | -| `TLS_FINGERPRINT_PROVIDERS` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Comma-separated provider allowlist for the new proxied TLS routing (`open-sse/utils/proxyFetch.ts`). Direct TLS keeps its legacy behavior when unset; only these providers route through the Chrome-124 fingerprint bridge. | +| `TLS_FINGERPRINT_PROVIDERS` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Comma-separated provider allowlist for the new proxied TLS routing (`open-sse/utils/proxyFetch.ts`). Direct TLS keeps its legacy behavior when unset; only these providers route through the Chrome-124 fingerprint bridge. Groq (`api.groq.com`) is excluded even when the allowlist is unset, because Chrome JA3 triggers Cloudflare 1010 while native undici reaches the API. | | `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | ### Scenarios diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index f060573f98..bef527cdae 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -85,12 +85,33 @@ function isTlsFingerprintEnabled() { return process.env.ENABLE_TLS_FINGERPRINT === "true"; } +function isGroqTlsFingerprintHost(url: string | null | undefined): boolean { + if (!url) return false; + try { + const host = new URL(url).hostname.toLowerCase(); + return host === "api.groq.com" || host.endsWith(".groq.com"); + } catch { + return /(?:^|[./])api\.groq\.com(?:[:/?]|$)/i.test(String(url)); + } +} + +function isGroqTlsFingerprintProvider( + provider: string | null | undefined +): boolean { + const normalized = provider?.trim().toLowerCase(); + return normalized === "groq"; +} + function tlsFingerprintProviderAllowed( provider: string | null | undefined, - proxied: boolean + proxied: boolean, + url?: string | null ): boolean { + if (isGroqTlsFingerprintProvider(provider) || isGroqTlsFingerprintHost(url)) { + return false; + } const configured = process.env.TLS_FINGERPRINT_PROVIDERS?.trim(); - // Preserve the legacy direct-only opt-in. The new proxied transport requires + // Preserve legacy direct-only opt-in. The new proxied transport requires // an explicit allowlist so enabling TLS cannot silently change proxy traffic. if (!configured) return !proxied; if (!provider) return false; @@ -793,7 +814,7 @@ async function patchedFetchUnrecorded( if ( isTlsFingerprintEnabled() && activeTlsClient.available && - tlsFingerprintProviderAllowed(tlsStore?.provider, false) && + tlsFingerprintProviderAllowed(tlsStore?.provider, false, targetUrl) && isTlsRequestEligible(input, options) ) { try { @@ -1085,7 +1106,7 @@ async function patchedFetchUnrecorded( typeof tlsStore?.sessionScope === "string" && tlsStore.sessionScope.trim().length > 0 && activeTlsClient.available && - tlsFingerprintProviderAllowed(tlsStore?.provider, true) && + tlsFingerprintProviderAllowed(tlsStore?.provider, true, targetUrl) && isTlsRequestEligible(input, options) && isWreqProxySupported(proxyUrl) ) { diff --git a/tests/unit/tls-proxy-context.test.ts b/tests/unit/tls-proxy-context.test.ts index 8312e8b584..2fee904bee 100644 --- a/tests/unit/tls-proxy-context.test.ts +++ b/tests/unit/tls-proxy-context.test.ts @@ -6,6 +6,7 @@ import { resolveProxyForRequest, runWithProxyContext, runWithTlsTracking, + isTlsFingerprintActive, setTlsClientForTest, } from "../../open-sse/utils/proxyFetch.ts"; import tlsClient, { @@ -321,6 +322,109 @@ test("new proxied TLS transport requires an explicit provider allowlist", async ); }); +test("direct TLS fingerprint skips Groq even when the provider allowlist is unset", async () => { + await withEnv( + { + ENABLE_TLS_FINGERPRINT: "true", + TLS_FINGERPRINT_PROVIDERS: undefined, + }, + async () => { + let tlsCalls = 0; + let dispatcherCalls = 0; + setTlsClientForTest( + fakeTlsClient(async () => { + tlsCalls++; + return new Response("tls"); + }), + ); + + const tracked = await runWithTlsTracking("groq", () => + proxyFetch("https://api.groq.com/openai/v1/models", {}, { + undiciFetch: async () => { + dispatcherCalls++; + return new Response("dispatcher"); + }, + }), + ); + + assert.equal(tlsCalls, 0); + assert.equal(dispatcherCalls, 1); + assert.equal(await tracked.result.text(), "dispatcher"); + assert.equal(tracked.tlsFingerprintUsed, false); + assert.equal(isTlsFingerprintActive("groq"), false); + }, + ); +}); + +test("direct TLS fingerprint skips api.groq.com when the tracking store has no provider", async () => { + await withEnv( + { + ENABLE_TLS_FINGERPRINT: "true", + TLS_FINGERPRINT_PROVIDERS: undefined, + }, + async () => { + let tlsCalls = 0; + let dispatcherCalls = 0; + setTlsClientForTest( + fakeTlsClient(async () => { + tlsCalls++; + return new Response("tls"); + }), + ); + + const tracked = await runWithTlsTracking(async () => + proxyFetch("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + body: "{}", + }, { + undiciFetch: async () => { + dispatcherCalls++; + return new Response("dispatcher"); + }, + }), + ); + + assert.equal(tlsCalls, 0); + assert.equal(dispatcherCalls, 1); + assert.equal(await tracked.result.text(), "dispatcher"); + assert.equal(tracked.tlsFingerprintUsed, false); + }, + ); +}); + +test("direct TLS fingerprint still spoofs non-Groq hosts when the allowlist is unset", async () => { + await withEnv( + { + ENABLE_TLS_FINGERPRINT: "true", + TLS_FINGERPRINT_PROVIDERS: undefined, + }, + async () => { + let tlsCalls = 0; + let dispatcherCalls = 0; + setTlsClientForTest( + fakeTlsClient(async () => { + tlsCalls++; + return new Response("tls"); + }), + ); + + const tracked = await runWithTlsTracking("openai", () => + proxyFetch("https://api.openai.com/v1/models", {}, { + undiciFetch: async () => { + dispatcherCalls++; + return new Response("dispatcher"); + }, + }), + ); + + assert.equal(tlsCalls, 1); + assert.equal(dispatcherCalls, 0); + assert.equal(await tracked.result.text(), "tls"); + assert.equal(tracked.tlsFingerprintUsed, true); + }, + ); +}); + test("caller abort propagates unchanged and never falls back", async () => { await withEnv( {