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.
This commit is contained in:
diegosouzapw
2026-05-21 04:02:04 -03:00
parent 6fe366db54
commit 6db9a77513
7 changed files with 812 additions and 45 deletions

View File

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

View File

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

View File

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

View File

@@ -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<unknown> | 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<unknown> };
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<T>(
promise: Promise<T>,
timeoutMs: number,
signal: AbortSignal | null | undefined
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
let abortListener: (() => void) | null = null;
try {
const racers: Promise<T>[] = [
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(
new TlsClientHangError(
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
)
);
}, timeoutMs);
}),
];
if (signal) {
racers.push(
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
}> {
if (!clientPromise) {
clientPromise = (async () => {
try {
const mod = await import("tls-client-node");
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
};
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<string, unknown>) => Promise<TlsResponseLike>;
}>;
}
interface TlsResponseLike {
status: number;
headers: Record<string, string[]>;
body: string; // for non-streaming requests, the full response body
cookies?: Record<string, string>;
text: () => Promise<string>;
bytes: () => Promise<Uint8Array>;
json: <T = unknown>() => Promise<T>;
}
export class TlsClientUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "TlsClientUnavailableError";
}
}
export interface TlsFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
signal?: AbortSignal | null;
/**
* If true, the response body is streamed to a temp file and exposed as a
* ReadableStream<Uint8Array>. 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<Uint8Array> | 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<TlsFetchResult>) | 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<TlsFetchResult> {
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<string, unknown> = {
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
url: string,
requestOptions: Record<string, unknown>,
eofSymbol = "[DONE]",
signal: AbortSignal | null = null,
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
): Promise<TlsFetchResult> {
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<void> {
await unlink(path).catch(() => {});
const dir = path.substring(0, path.lastIndexOf("/"));
await rmdir(dir).catch(() => {});
}
async function readFirstBytes(path: string, n: number): Promise<string> {
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<TlsResponseLike>
): Promise<boolean> {
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<TlsResponseLike>,
signal: AbortSignal | null = null
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
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<void> {
return new Promise((r) => setTimeout(r, ms));
}

View File

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

View File

@@ -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("<html><title>Just a moment...</title></html>"), true);
});
test("#2459 isCloudflareChallenge detects window._cf_chl_opt", () => {
assert.equal(isCloudflareChallenge("<script>window._cf_chl_opt={};</script>"), 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("<html>Just a moment</html>"), 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);
});

View File

@@ -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, [], "<html><title>Just a moment...</title></html>");
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();
}
});