import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js"; import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; import { redactSensitiveText } from "../logging/redact.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; export { isLoopbackHost }; /** * Returns true when the URL uses a WebSocket protocol (ws: or wss:). * Used to distinguish direct-WebSocket CDP endpoints * from HTTP(S) endpoints that require /json/version discovery. */ export function isWebSocketUrl(url: string): boolean { try { const parsed = new URL(url); return parsed.protocol === "ws:" || parsed.protocol === "wss:"; } catch { return false; } } export async function assertCdpEndpointAllowed( cdpUrl: string, ssrfPolicy?: SsrFPolicy, ): Promise { if (!ssrfPolicy) { return; } const parsed = new URL(cdpUrl); if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); } await resolvePinnedHostnameWithPolicy(parsed.hostname, { policy: ssrfPolicy, }); } export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined { if (typeof cdpUrl !== "string") { return cdpUrl; } const trimmed = cdpUrl.trim(); if (!trimmed) { return trimmed; } try { const parsed = new URL(trimmed); parsed.username = ""; parsed.password = ""; return redactSensitiveText(parsed.toString().replace(/\/$/, "")); } catch { return redactSensitiveText(trimmed); } } type CdpResponse = { id: number; result?: unknown; error?: { message?: string }; }; type Pending = { resolve: (value: unknown) => void; reject: (err: Error) => void; }; export type CdpSendFn = ( method: string, params?: Record, sessionId?: string, ) => Promise; export function getHeadersWithAuth(url: string, headers: Record = {}) { const mergedHeaders = { ...headers }; try { const parsed = new URL(url); const hasAuthHeader = Object.keys(mergedHeaders).some( (key) => key.toLowerCase() === "authorization", ); if (hasAuthHeader) { return mergedHeaders; } if (parsed.username || parsed.password) { const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64"); return { ...mergedHeaders, Authorization: `Basic ${auth}` }; } } catch { // ignore } return mergedHeaders; } export function appendCdpPath(cdpUrl: string, path: string): string { const url = new URL(cdpUrl); const basePath = url.pathname.replace(/\/$/, ""); const suffix = path.startsWith("/") ? path : `/${path}`; url.pathname = `${basePath}${suffix}`; return url.toString(); } export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { try { const url = new URL(cdpUrl); if (url.protocol === "ws:") { url.protocol = "http:"; } else if (url.protocol === "wss:") { url.protocol = "https:"; } url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, ""); url.pathname = url.pathname.replace(/\/cdp$/, ""); return url.toString().replace(/\/$/, ""); } catch { // Best-effort fallback for non-URL-ish inputs. return cdpUrl .replace(/^ws:/, "http:") .replace(/^wss:/, "https:") .replace(/\/devtools\/browser\/.*$/, "") .replace(/\/cdp$/, "") .replace(/\/$/, ""); } } function createCdpSender(ws: WebSocket) { let nextId = 1; const pending = new Map(); const send: CdpSendFn = ( method: string, params?: Record, sessionId?: string, ) => { const id = nextId++; const msg = { id, method, params, sessionId }; ws.send(JSON.stringify(msg)); return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); }); }; const closeWithError = (err: Error) => { for (const [, p] of pending) { p.reject(err); } pending.clear(); try { ws.close(); } catch { // ignore } }; ws.on("error", (err) => { closeWithError(err instanceof Error ? err : new Error(String(err))); }); ws.on("message", (data) => { try { const parsed = JSON.parse(rawDataToString(data)) as CdpResponse; if (typeof parsed.id !== "number") { return; } const p = pending.get(parsed.id); if (!p) { return; } pending.delete(parsed.id); if (parsed.error?.message) { p.reject(new Error(parsed.error.message)); return; } p.resolve(parsed.result); } catch { // ignore } }); ws.on("close", () => { closeWithError(new Error("CDP socket closed")); }); return { send, closeWithError }; } export async function fetchJson( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit, ): Promise { const res = await fetchCdpChecked(url, timeoutMs, init); return (await res.json()) as T; } export async function fetchCdpChecked( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit, ): Promise { const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); try { const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); const res = await withNoProxyForCdpUrl(url, () => fetch(url, { ...init, headers, signal: ctrl.signal }), ); if (!res.ok) { if (res.status === 429) { // Do not reflect upstream response text into the error surface (log/agent injection risk) throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`); } throw new Error(`HTTP ${res.status}`); } return res; } finally { clearTimeout(t); } } export async function fetchOk( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit, ): Promise { await fetchCdpChecked(url, timeoutMs, init); } export function openCdpWebSocket( wsUrl: string, opts?: { headers?: Record; handshakeTimeoutMs?: number }, ): WebSocket { const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); const handshakeTimeoutMs = typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) : CDP_WS_HANDSHAKE_TIMEOUT_MS; const agent = getDirectAgentForCdp(wsUrl); return new WebSocket(wsUrl, { handshakeTimeout: handshakeTimeoutMs, ...(Object.keys(headers).length ? { headers } : {}), ...(agent ? { agent } : {}), }); } export async function withCdpSocket( wsUrl: string, fn: (send: CdpSendFn) => Promise, opts?: { headers?: Record; handshakeTimeoutMs?: number }, ): Promise { const ws = openCdpWebSocket(wsUrl, opts); const { send, closeWithError } = createCdpSender(ws); const openPromise = new Promise((resolve, reject) => { ws.once("open", () => resolve()); ws.once("error", (err) => reject(err)); ws.once("close", () => reject(new Error("CDP socket closed"))); }); try { await openPromise; } catch (err) { closeWithError(err instanceof Error ? err : new Error(String(err))); throw err; } try { return await fn(send); } catch (err) { closeWithError(err instanceof Error ? err : new Error(String(err))); throw err; } finally { try { ws.close(); } catch { // ignore } } }