From 6db9a77513bca20936ece96d9b02a5b11fc2ffd7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 21 May 2026 04:02:04 -0300 Subject: [PATCH] fix(perplexity-web): TLS impersonation to bypass Cloudflare on VPS (#2459) New perplexityTlsClient.ts (Firefox-148 TLS profile, mirrors chatgptTlsClient) routes perplexity-web requests so Cloudflare stops 403-challenging datacenter IPs. Executor and connection validator now distinguish a Cloudflare block from an invalid session cookie. Adds OMNIROUTE_PPLX_TLS_TIMEOUT_MS / OMNIROUTE_PPLX_TLS_GRACE_MS. Co-authored analysis by havockdev. --- .env.example | 7 + docs/reference/ENVIRONMENT.md | 2 + open-sse/executors/perplexity-web.ts | 52 +- open-sse/services/perplexityTlsClient.ts | 597 +++++++++++++++++++++++ src/lib/providers/validation.ts | 79 ++- tests/unit/perplexity-tls-client.test.ts | 42 ++ tests/unit/perplexity-web.test.ts | 78 ++- 7 files changed, 812 insertions(+), 45 deletions(-) create mode 100644 open-sse/services/perplexityTlsClient.ts create mode 100644 tests/unit/perplexity-tls-client.test.ts diff --git a/.env.example b/.env.example index 1bda4388c7..a616c2cab2 100644 --- a/.env.example +++ b/.env.example @@ -706,6 +706,13 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS=60000 # OMNIROUTE_CLAUDE_TLS_GRACE_MS=10000 +# ── Perplexity TLS sidecar (Firefox-fingerprinted client) ── +# Used by: open-sse/services/perplexityTlsClient.ts — wire-level timeout for +# the bogdanfinn/tls-client koffi binding and the JS-side grace window +# layered on top of it when the native library is wedged. +# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000 +# OMNIROUTE_PPLX_TLS_GRACE_MS=10000 + # ── Circuit breaker thresholds and reset windows ── # Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts. # Defaults match historical PROVIDER_PROFILES values (post-scaling for diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 17458638e6..a322ccb7cb 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -528,6 +528,8 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | | `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). | | `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | +| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | ### Circuit Breaker Thresholds diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 3695f734aa..3d74340cb8 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -7,11 +7,19 @@ */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { + tlsFetchPerplexity, + isCloudflareChallenge, + TlsClientUnavailableError, + type TlsFetchResult, +} from "../services/perplexityTlsClient.ts"; const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; const PPLX_API_VERSION = "client-1.11.0"; +// Firefox 148 — must match the `firefox_148` TLS profile used by perplexityTlsClient. +// A mismatched UA vs TLS fingerprint is itself a Cloudflare bot signal (issue #2459). const PPLX_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"; const MODEL_MAP: Record = { "pplx-auto": ["concise", "pplx_pro"], @@ -721,23 +729,29 @@ export class PerplexityWebExecutor extends BaseExecutor { `Query to ${model} (pref=${modelPref}, mode=${pplxMode}), len=${query.length}` ); - // Fetch from Perplexity - const fetchOptions: RequestInit = { - method: "POST", - headers, - body: JSON.stringify(pplxBody), - }; - if (signal) fetchOptions.signal = signal; - - let response: Response; + // Fetch from Perplexity through the Firefox-fingerprinted TLS client. + // Perplexity sits behind Cloudflare Enterprise which pins JA3/JA4 to a real + // browser handshake; Node's fetch() is challenged with a 403 page from + // VPS/datacenter IPs even with a valid cookie (issue #2459). + let response: TlsFetchResult; try { - response = await fetch(PPLX_SSE_ENDPOINT, fetchOptions); + response = await tlsFetchPerplexity(PPLX_SSE_ENDPOINT, { + method: "POST", + headers, + body: JSON.stringify(pplxBody), + signal: signal ?? null, + stream: true, + streamEofSymbol: "[DONE]", + }); } catch (err) { + const isTlsUnavail = err instanceof TlsClientUnavailableError; log?.error?.("PPLX-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); const errResp = new Response( JSON.stringify({ error: { - message: `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`, + message: isTlsUnavail + ? `Perplexity TLS client unavailable: ${(err as Error).message}` + : `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`, type: "upstream_error", }, }), @@ -746,12 +760,20 @@ export class PerplexityWebExecutor extends BaseExecutor { return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; } - if (!response.ok) { + if (response.status !== 200 || (!response.body && !response.text)) { const status = response.status; let errMsg = `Perplexity returned HTTP ${status}`; if (status === 401 || status === 403) { - errMsg = - "Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token."; + if (isCloudflareChallenge(response.text)) { + errMsg = + "Cloudflare blocked the request — Perplexity's edge rejected this server's TLS fingerprint " + + "(common on VPS/datacenter IPs). Ensure tls-client-node is installed with its native binary, " + + "or route perplexity-web through a residential proxy."; + log?.error?.("PPLX-WEB", "Cloudflare challenge detected — TLS bypass failed"); + } else { + errMsg = + "Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token."; + } } else if (status === 429) { errMsg = "Perplexity rate limited. Wait a moment and retry."; } diff --git a/open-sse/services/perplexityTlsClient.ts b/open-sse/services/perplexityTlsClient.ts new file mode 100644 index 0000000000..debcd3e682 --- /dev/null +++ b/open-sse/services/perplexityTlsClient.ts @@ -0,0 +1,597 @@ +/** + * Browser-TLS-impersonating HTTP client for www.perplexity.ai. + * + * Why this exists: Perplexity sits behind the same Cloudflare Enterprise + * configuration as ChatGPT — it pins access to the client's TLS fingerprint + * (JA3/JA4) + HTTP/2 SETTINGS frame ordering. Node's Undici fetch presents an + * obvious "not a browser" handshake and gets challenged with a 403 "Just a + * moment..." page from VPS/datacenter IPs — even with a valid session cookie. + * This module wraps `tls-client-node` (native shared library built from + * bogdanfinn/tls-client) to send a Firefox handshake instead. (issue #2459) + * + * Mirrors `chatgptTlsClient.ts`; kept as an independent module so changes here + * cannot regress the production chatgpt-web path. The first call lazily starts + * the managed sidecar; subsequent calls reuse a singleton TLSClient. Process + * exit hooks stop the sidecar cleanly. + */ + +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +let clientPromise: Promise | null = null; +let exitHookInstalled = false; + +const PPLX_PROFILE = "firefox_148"; // matches the Firefox 148 UA we send +const DEFAULT_TIMEOUT_MS = + Number.parseInt(process.env.OMNIROUTE_PPLX_TLS_TIMEOUT_MS || "", 10) || 30_000; +// Grace period added to the binding's wire-level timeout before our JS-level +// hard timeout fires. Under healthy operation `tls-client-node` honors +// `timeoutMilliseconds` and rejects on its own; the JS-level race only wins +// when the koffi-loaded native library is wedged (which the binding's own +// timer can't escape). Keep the grace small so users don't wait noticeably +// longer than the configured timeout when the binding is dead. +const HARD_TIMEOUT_GRACE_MS = + Number.parseInt(process.env.OMNIROUTE_PPLX_TLS_GRACE_MS || "", 10) || 10_000; + +function installExitHook(): void { + if (exitHookInstalled) return; + exitHookInstalled = true; + const stop = async () => { + if (!clientPromise) return; + try { + const c = (await clientPromise) as { stop?: () => Promise }; + await c.stop?.(); + } catch { + // ignore + } + }; + process.once("beforeExit", stop); + process.once("SIGINT", () => { + void stop(); + }); + process.once("SIGTERM", () => { + void stop(); + }); +} + +/** + * Drop the cached client so the next `getClient()` call respawns it. Called + * when a request observes the native binding has wedged — releasing the + * reference lets a fresh TLSClient (and a fresh koffi load) take over without + * a process restart. + */ +function resetClientCache(): void { + clientPromise = null; +} + +export class TlsClientHangError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientHangError"; + } +} + +/** + * Race a `client.request()` promise against (a) a JS-level hard timeout and + * (b) the caller's abort signal. The native binding's `timeoutMilliseconds` + * already covers the wire path; this guards the case where the koffi binding + * itself deadlocks (observed after sustained load), where neither the + * binding's own timer nor a post-call `signal.aborted` re-check can recover. + */ +async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + signal: AbortSignal | null | undefined +): Promise { + let timer: ReturnType | null = null; + let abortListener: (() => void) | null = null; + try { + const racers: Promise[] = [ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new TlsClientHangError( + `tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked` + ) + ); + }, timeoutMs); + }), + ]; + if (signal) { + racers.push( + new Promise((_, reject) => { + if (signal.aborted) { + reject(makeAbortError(signal)); + return; + } + abortListener = () => reject(makeAbortError(signal)); + signal.addEventListener("abort", abortListener, { once: true }); + }) + ); + } + return await Promise.race(racers); + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } +} + +async function getClient(): Promise<{ + request: (url: string, opts: Record) => Promise; +}> { + if (!clientPromise) { + clientPromise = (async () => { + try { + const mod = await import("tls-client-node"); + const TLSClient = (mod as { TLSClient: new (opts?: Record) => unknown }) + .TLSClient; + // Native mode loads the shared library directly via koffi, avoiding the + // managed sidecar's localhost HTTP calls that OmniRoute's global fetch + // proxy patch interferes with. + const client = new TLSClient({ runtimeMode: "native" }) as { + start: () => Promise; + request: (url: string, opts: Record) => Promise; + }; + await client.start(); + + installExitHook(); + return client; + } catch (err) { + clientPromise = null; + const msg = err instanceof Error ? err.message : String(err); + throw new TlsClientUnavailableError( + `TLS impersonation client failed to start: ${msg}. ` + + `Verify tls-client-node is installed and its native binary downloaded.` + ); + } + })(); + } + return clientPromise as Promise<{ + request: (url: string, opts: Record) => Promise; + }>; +} + +interface TlsResponseLike { + status: number; + headers: Record; + body: string; // for non-streaming requests, the full response body + cookies?: Record; + text: () => Promise; + bytes: () => Promise; + json: () => Promise; +} + +export class TlsClientUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientUnavailableError"; + } +} + +export interface TlsFetchOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; + body?: string; + timeoutMs?: number; + signal?: AbortSignal | null; + /** + * If true, the response body is streamed to a temp file and exposed as a + * ReadableStream. Use for SSE responses (the perplexity_ask + * endpoint). Otherwise, the full body is read into memory. + */ + stream?: boolean; + /** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */ + streamEofSymbol?: string; + /** + * Optional upstream proxy URL (`http://user:pass@host:port` or + * `socks5://...`). When set, the request is tunneled through this proxy + * before reaching perplexity.ai. + * + * Resolution order: + * 1. `options.proxyUrl` (per-call override from caller) + * 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in) + * 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback) + * + * The native `tls-client-node` binding does **not** consult Go's + * `http.ProxyFromEnvironment`, so the env vars need to be plumbed in here at + * the JS layer. + */ + proxyUrl?: string; +} + +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; + +/** + * Resolve the proxy URL for a tls-client request. Per-call value wins; + * otherwise we use the standard proxy fetch resolution which reads from + * the dashboard AsyncLocalStorage context or falls back to env vars. + */ +function resolveProxyUrl(perCall: string | undefined): string | undefined { + if (perCall && perCall.length > 0) return perCall; + try { + const proxyInfo = resolveProxyForRequest("https://www.perplexity.ai"); + if (proxyInfo && proxyInfo.proxyUrl) { + return proxyInfo.proxyUrl; + } + } catch { + // Ignore resolution errors + } + return undefined; +} + +export interface TlsFetchResult { + status: number; + headers: Headers; + /** Full response body as text — only populated for non-streaming requests. */ + text: string | null; + /** Streaming body — only populated when options.stream === true. */ + body: ReadableStream | null; +} + +// Test-only injection point. Tests call __setTlsFetchOverrideForTesting() +// to replace the real TLS client with a mock; production never touches this. +let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = + null; + +export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { + testOverride = fn; +} + +/** + * Make a single HTTP request to perplexity.ai with a Firefox-like TLS fingerprint. + * + * Throws TlsClientUnavailableError if the native binary failed to load. + */ +export async function tlsFetchPerplexity( + url: string, + options: TlsFetchOptions = {} +): Promise { + if (testOverride) return testOverride(url, options); + // Honor abort signals up-front. tls-client-node's koffi binding doesn't + // accept an AbortSignal mid-flight (the binary call is opaque), so the best + // we can do is bail before issuing the call. We also re-check after — if + // the caller aborted while the upstream was running, throw rather than + // returning a stale response so the caller doesn't try to use it. + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + const client = await getClient(); + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + + const requestOptions: Record = { + method: options.method || "GET", + headers: options.headers || {}, + body: options.body, + tlsClientIdentifier: PPLX_PROFILE, + timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + followRedirects: true, + withRandomTLSExtensionOrder: true, + // Plumb the configured proxy through to the native binding. tls-client-node + // consults `proxyUrl` in the per-call options (it does NOT auto-pick up + // HTTP_PROXY / HTTPS_PROXY env), so callers / env have to be threaded in + // explicitly. See `resolveProxyUrl()` for the lookup order. + proxyUrl: resolveProxyUrl(options.proxyUrl), + }; + + if (options.stream) { + return await tlsFetchStreaming( + client, + url, + requestOptions, + options.streamEofSymbol, + options.signal ?? null, + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS + ); + } + + let tlsResponse: TlsResponseLike; + try { + tlsResponse = await raceWithTimeout( + client.request(url, requestOptions), + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS, + options.signal ?? null + ); + } catch (err) { + if (err instanceof TlsClientHangError) { + // The native binding is wedged — drop the singleton so the next + // request respawns a fresh client (and a fresh koffi load). + resetClientCache(); + } + throw err; + } + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; +} + +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + +function toHeaders(raw: Record): Headers { + const h = new Headers(); + for (const [k, vs] of Object.entries(raw || {})) { + for (const v of vs) h.append(k, v); + } + return h; +} + +/** + * Returns true if the response body is a Cloudflare challenge/interstitial page + * rather than a real Perplexity response. From VPS/datacenter IPs a valid cookie + * still gets a 403 "Just a moment..." HTML page; distinguishing it from a genuine + * auth failure lets the caller surface an actionable error (issue #2459). + * + * Exported so the executor and the connection validator share one detector. + */ +export function isCloudflareChallenge(text: string | null | undefined): boolean { + if (!text) return false; + return /just a moment|window\._cf_chl_opt|challenges\.cloudflare\.com|attention required|cf-chl/i.test( + text + ); +} + +// ─── Streaming via temp file ──────────────────────────────────────────────── +// tls-client-node's streaming primitive writes the response body chunk-by-chunk +// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`. +// We tail the file from a worker and surface the bytes as a ReadableStream. + +async function tlsFetchStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol = "[DONE]", + signal: AbortSignal | null = null, + hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS +): Promise { + const dir = await mkdtemp(join(tmpdir(), "pplx-stream-")); + const path = join(dir, `${randomUUID()}.sse`); + + const streamOpts = { + ...requestOptions, + streamOutputPath: path, + streamOutputBlockSize: 1024, + streamOutputEOFSymbol: eofSymbol, + }; + + // Kick off the request without awaiting — tls-client writes the body to + // `path` chunk-by-chunk while the call runs. The Promise resolves when the + // request fully completes (full body written). Wrapping in raceWithTimeout + // guarantees this promise eventually settles even if the koffi binding + // wedges; on hang we reset the singleton so the next request respawns. + let resetOnHang = true; + const requestPromise = raceWithTimeout( + client.request(url, streamOpts), + hardTimeoutMs, + signal + ).catch((err: unknown) => { + if (resetOnHang && err instanceof TlsClientHangError) { + resetClientCache(); + resetOnHang = false; + } + // Re-throw so downstream consumers (waitForContent, tailFile) observe + // the rejection and surface it instead of treating the stream as having + // ended cleanly. + throw err; + }); + + // Wait for the file to exist AND have at least one byte. tls-client-node + // creates the output file when the request starts, but the file can be + // empty for a brief window before the first body chunk lands — peeking + // during that window would return "" and misclassify the response as + // non-SSE, dropping us into the buffered-wait branch and silently turning + // a streaming request into a buffered one. Waiting for content avoids + // that race; if the request actually fails before producing any bytes, + // the timeout falls through to the requestPromise drain below (returning + // the real upstream status). + const ready = await waitForContent(path, 5_000, requestPromise); + if (!ready) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + await cleanupTempPath(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body, + body: null, + }; + } + + // Peek the first bytes to decide whether this looks like SSE. Anything + // that doesn't positively look like SSE (JSON `{...}`, HTML `<...>`, plain + // text rate-limit messages, Cloudflare challenge pages, etc.) gets surfaced + // as a non-streaming response so the executor sees the real upstream status + // and body — otherwise non-2xx error pages get silently treated as 200 OK + // and the SSE parser produces an empty completion. + const peek = await readFirstBytes(path, 256); + if (!looksLikeSse(peek)) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + await cleanupTempPath(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body, + body: null, + }; + } + + // Looks like SSE — start tailing. SSE bodies in practice are always 2xx; + // tls-client-node doesn't expose response status separately from full-body + // completion, so we report 200 and let the SSE parser consume the stream. + const stream = tailFile(path, eofSymbol, requestPromise, signal); + const headers = new Headers({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }); + return { status: 200, headers, text: null, body: stream }; +} + +/** + * Returns true if the peeked response body looks like an SSE stream — i.e., + * begins (after any leading whitespace) with one of the SSE field markers + * (`data:`, `event:`, `id:`, `retry:`) or a comment line (`:`). + * + * Exported for tests. + */ +export function looksLikeSse(text: string): boolean { + const trimmed = text.replace(/^[\s\r\n]+/, ""); + if (!trimmed) return false; + if (trimmed.startsWith(":")) return true; + return /^(data|event|id|retry):/i.test(trimmed); +} + +async function cleanupTempPath(path: string): Promise { + await unlink(path).catch(() => {}); + const dir = path.substring(0, path.lastIndexOf("/")); + await rmdir(dir).catch(() => {}); +} + +async function readFirstBytes(path: string, n: number): Promise { + const fd = await open(path, "r"); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fd.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString("utf8"); + } finally { + await fd.close().catch(() => {}); + } +} + +/** + * Wait for the streaming output file to exist AND contain at least one byte. + * Returns false if the request settles before any bytes arrive (so the caller + * can drain `requestPromise` and surface the real upstream status). Returns + * true as soon as the file has data — even one byte is enough for the SSE + * heuristic to give a useful answer. + */ +async function waitForContent( + path: string, + timeoutMs: number, + requestPromise: Promise +): Promise { + let requestSettled = false; + requestPromise.then( + () => { + requestSettled = true; + }, + () => { + requestSettled = true; + } + ); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const s = await stat(path); + if (s.size > 0) return true; + } catch { + // file doesn't exist yet + } + // If the request finished without producing any bytes, no point waiting + // out the rest of the timeout — let the caller drain it. + if (requestSettled) return false; + await sleep(25); + } + return false; +} + +function tailFile( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const fd = await open(path, "r"); + const buf = Buffer.alloc(64 * 1024); + let offset = 0; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + + // Track request settlement, capturing both fulfillment and rejection. + // Without the rejection branch, a mid-stream tls-client-node error + // becomes an unhandledRejection — the stream cleans up silently and + // the consumer sees what looks like a successful truncated response. + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + // If the caller aborts, stop tailing immediately. + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + let errored = false; + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + if (text.includes(eofSymbol)) { + const cutAt = text.indexOf(eofSymbol) + eofSymbol.length; + controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt))); + break; + } + controller.enqueue(new Uint8Array(chunk)); + } else if (finished) { + // No more data and request completed. If the request rejected, + // surface the error so the consumer doesn't think the stream + // ended cleanly. + if (upstreamError) { + controller.error(upstreamError); + errored = true; + } + break; + } else { + await sleep(25); + } + } + } catch (err) { + controller.error(err); + errored = true; + } finally { + if (signal) signal.removeEventListener("abort", onAbort); + await fd.close().catch(() => {}); + await unlink(path).catch(() => {}); + const dir = path.substring(0, path.lastIndexOf("/")); + await rmdir(dir).catch(() => {}); + if (!errored) controller.close(); + } + }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 107775e186..8a446a69e4 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -2948,8 +2948,9 @@ async function validatePerplexityWebProvider({ apiKey, providerSpecificData = {} Accept: "text/event-stream", Origin: "https://www.perplexity.ai", Referer: "https://www.perplexity.ai/", + // Firefox 148 — must match the firefox_148 TLS profile of perplexityTlsClient (issue #2459). "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/136.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", "X-App-ApiClient": "default", "X-App-ApiVersion": "client-1.11.0", ...(bearerToken @@ -2961,32 +2962,60 @@ async function validatePerplexityWebProvider({ apiKey, providerSpecificData = {} providerSpecificData ); - const response = await validationWrite("https://www.perplexity.ai/rest/sse/perplexity_ask", { - method: "POST", - headers, - body: JSON.stringify({ - query_str: "test", - params: { + // Perplexity is behind Cloudflare Enterprise which pins JA3/JA4 to a real + // browser handshake — plain fetch is challenged with a 403 page from + // VPS/datacenter IPs even with a valid cookie. Use the Firefox-fingerprinted + // TLS client so the validator's verdict reflects the cookie, not the IP (issue #2459). + const { tlsFetchPerplexity, isCloudflareChallenge, TlsClientUnavailableError } = + await import("@omniroute/open-sse/services/perplexityTlsClient.ts"); + + let response: { status: number; text: string | null }; + try { + response = await tlsFetchPerplexity("https://www.perplexity.ai/rest/sse/perplexity_ask", { + method: "POST", + headers, + body: JSON.stringify({ query_str: "test", - search_focus: "internet", - mode: "concise", - model_preference: "default", - sources: ["web"], - attachments: [], - frontend_uuid: crypto.randomUUID(), - frontend_context_uuid: crypto.randomUUID(), - version: "client-1.11.0", - language: "en-US", - timezone, - search_recency_filter: null, - is_incognito: true, - use_schematized_api: true, - last_backend_uuid: null, - }, - }), - }); + params: { + query_str: "test", + search_focus: "internet", + mode: "concise", + model_preference: "default", + sources: ["web"], + attachments: [], + frontend_uuid: crypto.randomUUID(), + frontend_context_uuid: crypto.randomUUID(), + version: "client-1.11.0", + language: "en-US", + timezone, + search_recency_filter: null, + is_incognito: true, + use_schematized_api: true, + last_backend_uuid: null, + }, + }), + timeoutMs: 30_000, + }); + } catch (err) { + if (err instanceof TlsClientUnavailableError) { + return { + valid: false, + error: `${err.message} perplexity-web requires it — without it Cloudflare blocks every request.`, + }; + } + throw err; + } if (response.status === 401 || response.status === 403) { + if (isCloudflareChallenge(response.text)) { + return { + valid: false, + error: + "Cloudflare is blocking connections from this server's IP (TLS fingerprint rejected). " + + "The session cookie may still be valid — install tls-client-node's native binary or route " + + "perplexity-web through a residential proxy.", + }; + } return { valid: false, error: @@ -2994,7 +3023,7 @@ async function validatePerplexityWebProvider({ apiKey, providerSpecificData = {} }; } - if (response.ok || (response.status >= 400 && response.status < 500)) { + if (response.status === 200 || (response.status >= 400 && response.status < 500)) { return { valid: true, error: null }; } diff --git a/tests/unit/perplexity-tls-client.test.ts b/tests/unit/perplexity-tls-client.test.ts new file mode 100644 index 0000000000..cad1b2af76 --- /dev/null +++ b/tests/unit/perplexity-tls-client.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { isCloudflareChallenge, looksLikeSse, TlsClientUnavailableError } = + await import("../../open-sse/services/perplexityTlsClient.ts"); + +// Regression for #2459: a Cloudflare 403 challenge page must be distinguishable from a +// genuine auth failure so the executor/validator can surface an actionable error. + +test("#2459 isCloudflareChallenge detects 'Just a moment' interstitial", () => { + assert.equal(isCloudflareChallenge("Just a moment..."), true); +}); + +test("#2459 isCloudflareChallenge detects window._cf_chl_opt", () => { + assert.equal(isCloudflareChallenge(""), true); +}); + +test("#2459 isCloudflareChallenge detects challenges.cloudflare.com", () => { + assert.equal(isCloudflareChallenge('src="https://challenges.cloudflare.com/turnstile"'), true); +}); + +test("#2459 isCloudflareChallenge returns false for normal JSON / SSE / empty", () => { + assert.equal(isCloudflareChallenge('{"status":"ok"}'), false); + assert.equal(isCloudflareChallenge('data: {"text":"hi"}\n\n'), false); + assert.equal(isCloudflareChallenge(""), false); + assert.equal(isCloudflareChallenge(null), false); + assert.equal(isCloudflareChallenge(undefined), false); +}); + +test("#2459 looksLikeSse positive for data/event markers, negative for HTML", () => { + assert.equal(looksLikeSse("data: {}\n\n"), true); + assert.equal(looksLikeSse("event: message\n"), true); + assert.equal(looksLikeSse(": comment"), true); + assert.equal(looksLikeSse("Just a moment"), false); + assert.equal(looksLikeSse('{"json":true}'), false); +}); + +test("#2459 TlsClientUnavailableError has the expected name", () => { + const err = new TlsClientUnavailableError("native binary missing"); + assert.equal(err.name, "TlsClientUnavailableError"); + assert.ok(err instanceof Error); +}); diff --git a/tests/unit/perplexity-web.test.ts b/tests/unit/perplexity-web.test.ts index a04c2d00a7..58e4787262 100644 --- a/tests/unit/perplexity-web.test.ts +++ b/tests/unit/perplexity-web.test.ts @@ -6,6 +6,21 @@ import assert from "node:assert/strict"; const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts"); const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); +const { __setTlsFetchOverrideForTesting, TlsClientUnavailableError } = + await import("../../open-sse/services/perplexityTlsClient.ts"); + +// #2459: the executor now routes through tlsFetchPerplexity (Firefox TLS) instead of +// global fetch. Install one persistent bridge so the tests below can keep stubbing +// globalThis.fetch (returning a Response) and have it surface as a TlsFetchResult. +__setTlsFetchOverrideForTesting(async (url, opts) => { + const res = await (globalThis.fetch as any)(url, opts); + return { + status: res.status, + headers: res.headers, + text: res.status === 200 ? null : await res.text(), + body: res.status === 200 ? res.body : null, + }; +}); // ─── Helper: Build a mock SSE stream from Perplexity events ───────────────── @@ -25,14 +40,22 @@ function mockPplxStream(events) { }); } -// ─── Helper: Override global fetch for testing ────────────────────────────── +// ─── Helper: stub globalThis.fetch for testing ────────────────────────────── +// The persistent bridge above forwards tlsFetchPerplexity calls to globalThis.fetch, +// so stubbing fetch is still the way to mock Perplexity's upstream response. -function mockFetch(status, streamEvents) { +function mockFetch(status, streamEvents, bodyText) { const original = globalThis.fetch; - globalThis.fetch = async (url, opts) => { - return new Response(mockPplxStream(streamEvents), { + globalThis.fetch = async () => { + if (status === 200) { + return new Response(mockPplxStream(streamEvents), { + status, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response(bodyText ?? `{"error":"http ${status}"}`, { status, - headers: { "Content-Type": "text/event-stream" }, + headers: { "Content-Type": "text/html" }, }); }; return () => { @@ -750,3 +773,48 @@ test("Request: posts to correct Perplexity SSE endpoint", async () => { globalThis.fetch = original; } }); + +// ─── #2459: Cloudflare challenge vs genuine auth failure ───────────────────── + +test("Error: Cloudflare 403 challenge returns a distinct (non-cookie) error", async () => { + const restore = mockFetch(403, [], "Just a moment..."); + try { + const executor = new PerplexityWebExecutor(); + const result = await executor.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "valid-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 403); + const json = (await result.response.json()) as any; + assert.match(json.error.message, /Cloudflare/i); + assert.ok(!/session-token/i.test(json.error.message), "must not blame the cookie"); + } finally { + restore(); + } +}); + +test("Error: TlsClientUnavailableError returns 502 with install hint", async () => { + const restore = mockFetchError(new TlsClientUnavailableError("native binary missing")); + try { + const executor = new PerplexityWebExecutor(); + const result = await executor.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 502); + const json = (await result.response.json()) as any; + assert.match(json.error.message, /TLS client unavailable/i); + } finally { + restore(); + } +});