From 1cc021251e4e84c97177b7a616d37776d1437284 Mon Sep 17 00:00:00 2001 From: shrey150 Date: Mon, 2 Mar 2026 01:08:15 -0800 Subject: [PATCH] test+docs: comprehensive coverage and generic framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 12 new tests covering: isWebSocketUrl detection, parseHttpUrl WSS acceptance/rejection, direct WS target creation with query params, SSRF enforcement on WS URLs, WS reachability probing bypasses HTTP - Reframe docs section as generic "Direct WebSocket CDP providers" with Browserbase as one example — any WSS-based provider works - Update security tips to mention WSS alongside HTTPS Co-Authored-By: Claude Opus 4.6 --- docs/tools/browser.md | 25 +++++---- src/browser/cdp.test.ts | 102 +++++++++++++++++++++++++++++++++++++ src/browser/chrome.test.ts | 10 ++++ 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index d02feaf3b55..e1372a08b9d 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -196,16 +196,23 @@ Notes: - Replace `` with your real Browserless token. - Choose the region endpoint that matches your Browserless account (see their docs). -## Browserbase (hosted remote CDP) +## Direct WebSocket CDP providers + +Some hosted browser services expose a **direct WebSocket** endpoint rather than +the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both: + +- **HTTP(S) endpoints** (e.g. Browserless) — OpenClaw calls `/json/version` to + discover the WebSocket debugger URL, then connects. +- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly, + skipping `/json/version`. Use this for services like + [Browserbase](https://www.browserbase.com) or any provider that hands you a + WebSocket URL. + +### Browserbase [Browserbase](https://www.browserbase.com) is a cloud platform for running -headless browsers. It provides remote CDP endpoints with built-in CAPTCHA -solving, stealth mode, and residential proxies. Unlike Browserless (which -exposes a standard HTTP-based CDP discovery endpoint), Browserbase uses a -direct WebSocket connection — OpenClaw connects to `wss://connect.browserbase.com` -and authenticates via your API key in the query string. - -Example: +headless browsers with built-in CAPTCHA solving, stealth mode, and residential +proxies. ```json5 { @@ -247,7 +254,7 @@ Key ideas: Remote CDP tips: -- Prefer HTTPS endpoints and short-lived tokens where possible. +- Prefer encrypted endpoints (HTTPS or WSS) and short-lived tokens where possible. - Avoid embedding long-lived tokens directly in config files. ## Profiles (multi-browser) diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 8b988c69860..9976869b9dd 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type WebSocket, WebSocketServer } from "ws"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; +import { isWebSocketUrl } from "./cdp.helpers.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; +import { parseHttpUrl } from "./config.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; describe("cdp", () => { @@ -123,6 +125,51 @@ describe("cdp", () => { } }); + it("preserves query params when connecting via direct WebSocket URL", async () => { + let receivedHeaders: Record = {}; + const wsPort = await startWsServer(); + if (!wsServer) { + throw new Error("ws server not initialized"); + } + wsServer.on("headers", (headers, req) => { + receivedHeaders = Object.fromEntries( + Object.entries(req.headers).map(([k, v]) => [k, String(v)]), + ); + }); + wsServer.on("connection", (socket) => { + socket.on("message", (data) => { + const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string }; + if (msg.method === "Target.createTarget") { + socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_QP" } })); + } + }); + }); + + const created = await createTargetViaCdp({ + cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST?apiKey=secret123`, + url: "https://example.com", + }); + expect(created.targetId).toBe("T_QP"); + // The WebSocket upgrade request should have been made to the URL with the query param + expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`); + }); + + it("still enforces SSRF policy for direct WebSocket URLs", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + await expect( + createTargetViaCdp({ + cdpUrl: "ws://127.0.0.1:9222", + url: "http://127.0.0.1:8080", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + // SSRF check happens before any connection attempt + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks private navigation targets by default", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); try { @@ -281,3 +328,58 @@ describe("cdp", () => { expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc"); }); }); + +describe("isWebSocketUrl", () => { + it("returns true for ws:// URLs", () => { + expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true); + expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true); + }); + + it("returns true for wss:// URLs", () => { + expect(isWebSocketUrl("wss://connect.example.com")).toBe(true); + expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true); + }); + + it("returns false for http:// and https:// URLs", () => { + expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false); + expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false); + }); + + it("returns false for invalid or non-URL strings", () => { + expect(isWebSocketUrl("not-a-url")).toBe(false); + expect(isWebSocketUrl("")).toBe(false); + expect(isWebSocketUrl("ftp://example.com")).toBe(false); + }); +}); + +describe("parseHttpUrl with WebSocket protocols", () => { + it("accepts wss:// URLs and defaults to port 443", () => { + const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test"); + expect(result.parsed.protocol).toBe("wss:"); + expect(result.port).toBe(443); + expect(result.normalized).toContain("wss://connect.example.com"); + }); + + it("accepts ws:// URLs and defaults to port 80", () => { + const result = parseHttpUrl("ws://127.0.0.1/devtools", "test"); + expect(result.parsed.protocol).toBe("ws:"); + expect(result.port).toBe(80); + }); + + it("preserves explicit ports in wss:// URLs", () => { + const result = parseHttpUrl("wss://connect.example.com:8443/path", "test"); + expect(result.port).toBe(8443); + }); + + it("still accepts http:// and https:// URLs", () => { + const http = parseHttpUrl("http://127.0.0.1:9222", "test"); + expect(http.port).toBe(9222); + const https = parseHttpUrl("https://browserless.example?token=abc", "test"); + expect(https.port).toBe(443); + }); + + it("rejects unsupported protocols", () => { + expect(() => parseHttpUrl("ftp://example.com", "test")).toThrow("must be http(s) or ws(s)"); + expect(() => parseHttpUrl("file:///etc/passwd", "test")).toThrow("must be http(s) or ws(s)"); + }); +}); diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index 467a09be0f2..dcbd32fd13c 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -350,6 +350,16 @@ describe("browser chrome helpers", () => { }); }); + it("probes WebSocket URLs via handshake instead of HTTP", async () => { + // For ws:// URLs, isChromeReachable should NOT call fetch at all — + // it should attempt a WebSocket handshake instead. + const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called")); + vi.stubGlobal("fetch", fetchSpy); + // No WS server listening → handshake fails → not reachable + await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("stopOpenClawChrome no-ops when process is already killed", async () => { const proc = makeChromeTestProc({ killed: true }); await stopChromeWithProc(proc, 10);