diff --git a/CHANGELOG.md b/CHANGELOG.md index 627fcba68b1d..d508a9b71b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Require auth for sandbox browser CDP relay [AI]. (#81002) Thanks @pgondhi987. - fix: detect carried exec command forms [AI]. (#81000) Thanks @pgondhi987. - Reject truncated exec approval commands [AI]. (#81001) Thanks @pgondhi987. - Enforce inline shell wrapper payload matching [AI]. (#80978) Thanks @pgondhi987. diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index 9462ead6e665..779127968186 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -123,6 +123,28 @@ describe("cdp helpers", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("sends URL credentials as an auth header for guarded CDP fetches", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { + ok: true, + status: 200, + }, + release, + }); + + await expect( + fetchOk("http://openclaw:relay-token@127.0.0.1:9222/json/version", 250), + ).resolves.toBeUndefined(); + + const request = requireGuardedFetchRequest(); + expect(request?.url).toBe("http://127.0.0.1:9222/json/version"); + expect(request?.init?.headers).toEqual({ + Authorization: "Basic b3BlbmNsYXc6cmVsYXktdG9rZW4=", + }); + expect(release).toHaveBeenCalledTimes(1); + }); + it("preserves hostname allowlist while allowing exact loopback CDP fetches", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValueOnce({ diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index 0204680859cb..e59dd3824c27 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -187,6 +187,20 @@ export function getHeadersWithAuth(url: string, headers: Record return mergedHeaders; } +function stripUrlCredentials(url: string): string { + try { + const parsed = new URL(url); + if (!parsed.username && !parsed.password) { + return url; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } catch { + return url; + } +} + export function appendCdpPath(cdpUrl: string, path: string): string { const url = new URL(cdpUrl); const basePath = url.pathname.replace(/\/$/, ""); @@ -350,13 +364,14 @@ export async function fetchCdpChecked( }; try { const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); + const fetchUrl = stripUrlCredentials(url); const res = await withNoProxyForCdpUrl(url, async () => { - const parsedUrl = new URL(url); + const parsedUrl = new URL(fetchUrl); const policy = isLoopbackHost(parsedUrl.hostname) ? withAllowedHostname(ssrfPolicy, parsedUrl.hostname) : (ssrfPolicy ?? { allowPrivateNetwork: true }); const guarded = await fetchWithSsrFGuard({ - url, + url: fetchUrl, init: { ...init, headers }, signal: ctrl.signal, policy, diff --git a/extensions/browser/src/browser/routes/basic.existing-session.test.ts b/extensions/browser/src/browser/routes/basic.existing-session.test.ts index b214ff334248..cd03be3fcefe 100644 --- a/extensions/browser/src/browser/routes/basic.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/basic.existing-session.test.ts @@ -56,7 +56,7 @@ function readFirstReachabilityCall( return call; } -function createManagedProfileState() { +function createManagedProfileState(profileOverrides?: Record) { return { resolved: { enabled: true, @@ -80,6 +80,7 @@ function createManagedProfileState() { headless: false, headlessSource: "default", attachOnly: false, + ...profileOverrides, }, isHttpReachable: async () => false, isTransportAvailable: async () => false, @@ -215,6 +216,19 @@ describe("basic browser routes", () => { expect(body.headlessSource).toBe("request"); }); + it("redacts CDP URL credentials from status responses", async () => { + const response = await callBasicRouteWithState({ + query: { profile: "openclaw" }, + state: createManagedProfileState({ + cdpUrl: "http://openclaw:relay-token@127.0.0.1:18800", + }), + }); + + expect(response.statusCode).toBe(200); + const body = responseBodyRecord(response); + expect(body.cdpUrl).toBe("http://127.0.0.1:18800"); + }); + it("maps existing-session status failures to JSON browser errors", async () => { const response = await callBasicRouteWithState({ state: createExistingSessionProfileState({ diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index 72382b1a85d7..e2cfa178064c 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -1,3 +1,4 @@ +import { redactCdpUrl } from "../cdp.helpers.js"; import { snapshotAria } from "../cdp.js"; import { getChromeMcpPid } from "../chrome-mcp.js"; import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js"; @@ -163,7 +164,7 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext) ? getChromeMcpPid(profileCtx.profile.name) : (profileState?.running?.pid ?? null), cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort, - cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl, + cdpUrl: capabilities.usesChromeMcp ? null : (redactCdpUrl(profileCtx.profile.cdpUrl) ?? null), chosenBrowser: profileState?.running?.exe.kind ?? null, detectedBrowser, detectedExecutablePath, diff --git a/extensions/browser/src/browser/server-context.list-profiles.test.ts b/extensions/browser/src/browser/server-context.list-profiles.test.ts index 3944dddc96a2..e0ac776eee13 100644 --- a/extensions/browser/src/browser/server-context.list-profiles.test.ts +++ b/extensions/browser/src/browser/server-context.list-profiles.test.ts @@ -61,4 +61,36 @@ describe("browser server-context listProfiles", () => { expect(profiles[0]?.name).toBe("manual-cdp"); expect(profiles[0]?.running).toBe(true); }); + + it("redacts CDP URL credentials from profile status", async () => { + const state = makeBrowserServerState({ + profile: { + name: "manual-cdp", + cdpUrl: "http://openclaw:relay-token@127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + cdpPort: 9222, + color: "#00AA00", + driver: "openclaw", + headless: false, + attachOnly: true, + }, + resolvedOverrides: { + defaultProfile: "manual-cdp", + ssrfPolicy: {}, + }, + }); + const isChromeReachable = vi.mocked(chromeModule.isChromeReachable); + isChromeReachable.mockResolvedValue(true); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const profiles = await ctx.listProfiles(); + + expect(isChromeReachable).toHaveBeenCalledWith( + "http://openclaw:relay-token@127.0.0.1:9222", + state.resolved.remoteCdpTimeoutMs, + undefined, + ); + expect(profiles[0]?.cdpUrl).toBe("http://127.0.0.1:9222"); + }); }); diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index a624b5a89064..c07d63bfb2fa 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -3,6 +3,7 @@ import { resolveCdpReachabilityPolicy, } from "./cdp-reachability-policy.js"; import { usesFastLoopbackCdpProbeClass } from "./cdp-timeouts.js"; +import { redactCdpUrl } from "./cdp.helpers.js"; import { listChromeMcpTabs } from "./chrome-mcp.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; @@ -226,7 +227,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon name, transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", cdpPort: capabilities.usesChromeMcp ? null : profile.cdpPort, - cdpUrl: capabilities.usesChromeMcp ? null : profile.cdpUrl, + cdpUrl: capabilities.usesChromeMcp ? null : (redactCdpUrl(profile.cdpUrl) ?? null), color: profile.color, driver: profile.driver, running, diff --git a/scripts/docker/sandbox/Dockerfile.browser b/scripts/docker/sandbox/Dockerfile.browser index ffc898886433..b456a61225c3 100644 --- a/scripts/docker/sandbox/Dockerfile.browser +++ b/scripts/docker/sandbox/Dockerfile.browser @@ -2,6 +2,8 @@ FROM debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252 +LABEL org.openclaw.sandbox-browser.contract="2026-05-12-cdp-relay-auth" + ENV DEBIAN_FRONTEND=noninteractive RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ @@ -19,7 +21,6 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/ jq \ novnc \ python3 \ - socat \ websockify \ x11vnc \ xvfb diff --git a/scripts/sandbox-browser-entrypoint.sh b/scripts/sandbox-browser-entrypoint.sh index 258bdac28efe..ed8c60057dc3 100755 --- a/scripts/sandbox-browser-entrypoint.sh +++ b/scripts/sandbox-browser-entrypoint.sh @@ -10,6 +10,7 @@ export XDG_CACHE_HOME="${HOME}/.cache" CDP_PORT="${OPENCLAW_BROWSER_CDP_PORT:-9222}" CDP_SOURCE_RANGE="${OPENCLAW_BROWSER_CDP_SOURCE_RANGE:-}" +CDP_AUTH_TOKEN="${OPENCLAW_BROWSER_CDP_AUTH_TOKEN:-}" VNC_PORT="${OPENCLAW_BROWSER_VNC_PORT:-5900}" NOVNC_PORT="${OPENCLAW_BROWSER_NOVNC_PORT:-6080}" ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-1}" @@ -53,7 +54,7 @@ cleanup() { local pids=() local pid - for pid in "${WEBSOCKIFY_PID:-}" "${X11VNC_PID:-}" "${SOCAT_PID:-}" "${CHROME_PID:-}" "${XVFB_PID:-}"; do + for pid in "${WEBSOCKIFY_PID:-}" "${X11VNC_PID:-}" "${CDP_RELAY_PID:-}" "${CHROME_PID:-}" "${XVFB_PID:-}"; do if [[ -n "${pid:-}" ]]; then pids+=("$pid") fi @@ -172,17 +173,140 @@ if [[ "${CDP_READY}" == "0" ]]; then exit 1 fi -echo "[sandbox] CDP ready. Starting socat..." +echo "[sandbox] CDP ready. Starting relay..." -if [[ -z "${CDP_SOURCE_RANGE}" ]]; then - echo "[sandbox-browser] WARNING: CDP_SOURCE_RANGE unset; socat CDP relay will not start." >&2 - echo "[sandbox-browser] Set OPENCLAW_BROWSER_CDP_SOURCE_RANGE to an explicit CIDR to enable CDP access." >&2 +if [[ -z "${CDP_AUTH_TOKEN}" ]]; then + echo "[sandbox-browser] WARNING: CDP auth token unset; CDP relay will not start." >&2 else - SOCAT_LISTEN_ADDR="TCP-LISTEN:${CDP_PORT},fork,reuseaddr,bind=0.0.0.0" - SOCAT_LISTEN_ADDR="${SOCAT_LISTEN_ADDR},range=${CDP_SOURCE_RANGE}" - socat "${SOCAT_LISTEN_ADDR}" "TCP:127.0.0.1:${CHROME_CDP_PORT}" & - SOCAT_PID=$! - echo "[sandbox] socat started (PID: ${SOCAT_PID})" + OPENCLAW_BROWSER_CHROME_CDP_PORT="${CHROME_CDP_PORT}" python3 - <<'PY' & +import base64 +import hmac +import ipaddress +import os +import select +import socket +import socketserver +import sys +import time + +LISTEN_PORT = int(os.environ["OPENCLAW_BROWSER_CDP_PORT"]) +UPSTREAM_PORT = int(os.environ["OPENCLAW_BROWSER_CHROME_CDP_PORT"]) +AUTH_TOKEN = os.environ["OPENCLAW_BROWSER_CDP_AUTH_TOKEN"] +SOURCE_RANGE = os.environ.get("OPENCLAW_BROWSER_CDP_SOURCE_RANGE", "").strip() +MAX_HEADER_BYTES = 65536 +HEADER_READ_TIMEOUT_SECONDS = 5.0 + +try: + SOURCE_NETWORK = ipaddress.ip_network(SOURCE_RANGE, strict=False) if SOURCE_RANGE else None +except ValueError: + print(f"[sandbox-browser] ERROR: invalid CDP source range: {SOURCE_RANGE}", file=sys.stderr) + raise SystemExit(1) + +EXPECTED_BASIC = "Basic " + base64.b64encode(f"openclaw:{AUTH_TOKEN}".encode()).decode() +EXPECTED_BEARER = "Bearer " + AUTH_TOKEN + + +def source_allowed(host): + if SOURCE_NETWORK is None: + return True + try: + return ipaddress.ip_address(host) in SOURCE_NETWORK + except ValueError: + return False + + +def has_auth(header_bytes): + try: + text = header_bytes.decode("iso-8859-1") + except UnicodeDecodeError: + return False + for line in text.split("\r\n")[1:]: + name, sep, value = line.partition(":") + if sep and name.strip().lower() == "authorization": + auth = value.strip() + basic_ok = hmac.compare_digest(auth, EXPECTED_BASIC) + bearer_ok = hmac.compare_digest(auth, EXPECTED_BEARER) + return basic_ok or bearer_ok + return False + + +def read_headers(conn, deadline): + data = b"" + while b"\r\n\r\n" not in data: + remaining = deadline - time.monotonic() + if remaining <= 0: + return b"" + conn.settimeout(remaining) + try: + chunk = conn.recv(4096) + except socket.timeout: + return b"" + if not chunk: + return b"" + data += chunk + if len(data) > MAX_HEADER_BYTES: + return b"" + return data + + +def relay(left, right): + sockets = [left, right] + try: + while sockets: + readable, _, _ = select.select(sockets, [], []) + for src in readable: + dst = right if src is left else left + data = src.recv(65536) + if not data: + return + dst.sendall(data) + finally: + for sock in (left, right): + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + sock.close() + except OSError: + pass + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + client_host = self.client_address[0] + if not source_allowed(client_host): + return + header_deadline = time.monotonic() + HEADER_READ_TIMEOUT_SECONDS + header_bytes = read_headers(self.request, header_deadline) + if not header_bytes: + return + if not has_auth(header_bytes): + self.request.sendall( + b"HTTP/1.1 401 Unauthorized\r\n" + b'WWW-Authenticate: Basic realm="OpenClaw CDP"\r\n' + b"Connection: close\r\n" + b"Content-Length: 0\r\n\r\n" + ) + return + upstream = socket.create_connection(("127.0.0.1", UPSTREAM_PORT), timeout=5) + upstream.settimeout(None) + self.request.settimeout(None) + upstream.sendall(header_bytes) + relay(self.request, upstream) + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +with Server(("0.0.0.0", LISTEN_PORT), Handler) as server: + print("[sandbox] CDP relay started", flush=True) + server.serve_forever() +PY + CDP_RELAY_PID=$! + echo "[sandbox] CDP relay started (PID: ${CDP_RELAY_PID})" fi if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 09d8264dfbb0..6770b216c3a3 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -1,4 +1,6 @@ +import { readFileSync } from "node:fs"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH } from "./constants.js"; import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js"; import type { SandboxConfig } from "./types.js"; import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; @@ -60,14 +62,18 @@ vi.mock("../../plugin-sdk/browser-profiles.js", () => ({ resolved: { cdpHost: string; cdpIsLoopback: boolean; profiles?: Record }, profileName: string, ) => { - const profile = resolved.profiles?.[profileName] as { cdpPort?: number; color?: string }; + const profile = resolved.profiles?.[profileName] as { + cdpPort?: number; + cdpUrl?: string; + color?: string; + }; if (typeof profile?.cdpPort !== "number") { return null; } return { name: profileName, cdpPort: profile.cdpPort, - cdpUrl: `http://${resolved.cdpHost}:${profile.cdpPort}`, + cdpUrl: profile.cdpUrl ?? `http://${resolved.cdpHost}:${profile.cdpPort}`, cdpHost: resolved.cdpHost, cdpIsLoopback: resolved.cdpIsLoopback, color: profile.color ?? "#FF4500", @@ -192,7 +198,7 @@ describe("ensureSandboxBrowser create args", () => { dockerMocks.dockerContainerState.mockResolvedValue({ exists: false, running: false }); dockerMocks.execDocker.mockImplementation(async (args: string[]) => { if (args[0] === "image" && args[1] === "inspect") { - return { stdout: "[]", stderr: "", code: 0 }; + return { stdout: `${SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH}\n`, stderr: "", code: 0 }; } return { stdout: "", stderr: "", code: 0 }; }); @@ -225,6 +231,40 @@ describe("ensureSandboxBrowser create args", () => { bridgeMocks.stopBrowserBridgeServer.mockResolvedValue(undefined); }); + it("rejects stale sandbox browser images without the relay auth contract", async () => { + dockerMocks.execDocker.mockImplementation(async (args: string[]) => { + if (args[0] === "image" && args[1] === "inspect") { + return { stdout: "\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }); + + await expect( + ensureTestSandboxBrowser({ + scopeKey: "session:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: buildConfig(false), + }), + ).rejects.toThrow( + "Sandbox browser image openclaw-sandbox-browser:bookworm-slim is stale or incompatible", + ); + + expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeUndefined(); + }); + + it("keeps the browser Dockerfile contract label aligned with the runtime constant", () => { + const dockerfile = readFileSync( + new URL("../../../scripts/docker/sandbox/Dockerfile.browser", import.meta.url), + "utf8", + ); + const label = dockerfile.match( + /^LABEL org\.openclaw\.sandbox-browser\.contract="([^"]+)"$/m, + )?.[1]; + + expect(label).toBe(SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH); + }); + it("publishes noVNC on loopback and injects noVNC password env", async () => { const result = await ensureTestSandboxBrowser({ scopeKey: "session:test", @@ -506,7 +546,7 @@ describe("ensureSandboxBrowser create args", () => { ); }); - it("auto-derives CDP source range from Docker network gateway", async () => { + it("requires auth for the sandbox CDP relay without auto-derived source ranges", async () => { dockerMocks.readDockerNetworkGateway.mockResolvedValue("172.21.0.1"); await ensureTestSandboxBrowser({ @@ -518,10 +558,26 @@ describe("ensureSandboxBrowser create args", () => { const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); const envEntries = collectDockerFlagValues(createArgs ?? [], "-e"); - expect(envEntries).toContain("OPENCLAW_BROWSER_CDP_SOURCE_RANGE=172.21.0.1/32"); + const authEntry = envEntries.find((entry) => + entry.startsWith("OPENCLAW_BROWSER_CDP_AUTH_TOKEN="), + ); + expect(authEntry).toMatch(/^OPENCLAW_BROWSER_CDP_AUTH_TOKEN=[0-9a-f]{48}$/); + expect(envEntries).not.toContain("OPENCLAW_BROWSER_CDP_SOURCE_RANGE=172.21.0.1/32"); + expect(dockerMocks.readDockerNetworkDriver).not.toHaveBeenCalled(); + expect(dockerMocks.readDockerNetworkGateway).not.toHaveBeenCalled(); + + const token = requireValue(authEntry, "CDP auth env").slice( + "OPENCLAW_BROWSER_CDP_AUTH_TOKEN=".length, + ); + const profiles = latestBridgeResolved().profiles as Record< + string, + { cdpPort?: number; cdpUrl?: string } + >; + expect(profiles.openclaw?.cdpPort).toBe(49100); + expect(profiles.openclaw?.cdpUrl).toBe(`http://openclaw:${token}@127.0.0.1:49100`); }); - it("uses explicit cdpSourceRange over auto-derived gateway", async () => { + it("passes explicit cdpSourceRange as an additional relay filter", async () => { dockerMocks.readDockerNetworkGateway.mockResolvedValue("172.21.0.1"); const cfg = buildConfig(false); cfg.browser.cdpSourceRange = "10.0.0.0/24"; @@ -539,49 +595,25 @@ describe("ensureSandboxBrowser create args", () => { expect(dockerMocks.readDockerNetworkGateway).not.toHaveBeenCalled(); }); - it("rejects IPv6-only gateway (relay binds IPv4)", async () => { - dockerMocks.readDockerNetworkGateway.mockResolvedValue("fd12::1"); + it("recreates existing browser containers that do not expose relay auth", async () => { + dockerMocks.dockerContainerState.mockResolvedValue({ exists: true, running: true }); + dockerMocks.readDockerContainerEnvVar.mockResolvedValue(null); - await expect( - ensureTestSandboxBrowser({ - scopeKey: "session:test", - workspaceDir: "/tmp/workspace", - agentWorkspaceDir: "/tmp/workspace", - cfg: buildConfig(false), - }), - ).rejects.toThrow(/Cannot derive CDP source range/); + await ensureTestSandboxBrowser({ + scopeKey: "session:test", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: buildConfig(false), + }); + + expect(dockerMocks.execDocker).toHaveBeenCalledWith( + ["rm", "-f", "openclaw-sbx-browser-session-test-0661d10a"], + { allowFailure: true }, + ); + expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeDefined(); }); - it("throws when CDP source range cannot be derived", async () => { - dockerMocks.readDockerNetworkGateway.mockResolvedValue(null); - - await expect( - ensureTestSandboxBrowser({ - scopeKey: "session:test", - workspaceDir: "/tmp/workspace", - agentWorkspaceDir: "/tmp/workspace", - cfg: buildConfig(false), - }), - ).rejects.toThrow(/Cannot derive CDP source range/); - }); - - it("requires explicit cdpSourceRange for non-bridge network drivers", async () => { - dockerMocks.readDockerNetworkDriver.mockResolvedValue("macvlan"); - dockerMocks.readDockerNetworkGateway.mockResolvedValue("172.21.0.1"); - - await expect( - ensureTestSandboxBrowser({ - scopeKey: "session:test", - workspaceDir: "/tmp/workspace", - agentWorkspaceDir: "/tmp/workspace", - cfg: buildConfig(false), - }), - ).rejects.toThrow(/Cannot derive CDP source range/); - // Gateway helper should not have been called for non-bridge networks. - expect(dockerMocks.readDockerNetworkGateway).not.toHaveBeenCalled(); - }); - - it("uses loopback range for network=none (no IPAM gateway, no peer risk)", async () => { + it("does not inject a source range for network=none by default", async () => { dockerMocks.readDockerNetworkGateway.mockResolvedValue(null); const cfg = buildConfig(false); cfg.browser.network = "none"; @@ -596,6 +628,8 @@ describe("ensureSandboxBrowser create args", () => { requireValue(result, "sandbox browser result"); const createArgs = requireDockerCreateArgs(); const envEntries = collectDockerFlagValues(createArgs, "-e"); - expect(envEntries).toContain("OPENCLAW_BROWSER_CDP_SOURCE_RANGE=127.0.0.1/32"); + expect(envEntries.some((entry) => entry.startsWith("OPENCLAW_BROWSER_CDP_SOURCE_RANGE="))).toBe( + false, + ); }); }); diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index ca6ee0386b4d..4d8158a5ed8e 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -21,7 +21,11 @@ import { import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { computeSandboxBrowserConfigHash } from "./config-hash.js"; import { resolveSandboxBrowserDockerCreateConfig } from "./config.js"; -import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "./constants.js"; +import { + DEFAULT_SANDBOX_BROWSER_IMAGE, + SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH, + SANDBOX_BROWSER_SECURITY_HASH_EPOCH, +} from "./constants.js"; import { buildSandboxCreateArgs, dockerContainerState, @@ -30,8 +34,6 @@ import { isDockerDaemonUnavailable, readDockerContainerEnvVar, readDockerContainerLabel, - readDockerNetworkDriver, - readDockerNetworkGateway, readDockerPort, } from "./docker.js"; import { @@ -51,8 +53,25 @@ import { appendWorkspaceMountArgs, SANDBOX_MOUNT_FORMAT_VERSION } from "./worksp const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000; const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE"; +const CDP_AUTH_TOKEN_ENV_KEY = "OPENCLAW_BROWSER_CDP_AUTH_TOKEN"; +const SANDBOX_BROWSER_IMAGE_CONTRACT_LABEL = "org.openclaw.sandbox-browser.contract"; -async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }): Promise { +function buildSandboxCdpAuthHeader(token: string): string { + return `Basic ${Buffer.from(`openclaw:${token}`).toString("base64")}`; +} + +function buildSandboxCdpUrl(params: { cdpPort: number; authToken: string }): string { + const url = new URL(`http://127.0.0.1:${params.cdpPort}`); + url.username = "openclaw"; + url.password = params.authToken; + return url.toString().replace(/\/$/, ""); +} + +async function waitForSandboxCdp(params: { + cdpPort: number; + authToken: string; + timeoutMs: number; +}): Promise { const deadline = Date.now() + Math.max(0, params.timeoutMs); const url = `http://127.0.0.1:${params.cdpPort}/json/version`; while (Date.now() < deadline) { @@ -60,7 +79,10 @@ async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }) const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), 1000); try { - const res = await fetch(url, { signal: ctrl.signal }); + const res = await fetch(url, { + headers: { Authorization: buildSandboxCdpAuthHeader(params.authToken) }, + signal: ctrl.signal, + }); if (res.ok) { return true; } @@ -82,6 +104,7 @@ async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }) function buildSandboxBrowserResolvedConfig(params: { controlPort: number; cdpPort: number; + cdpAuthToken: string; headless: boolean; evaluateEnabled: boolean; ssrfPolicy?: SsrFPolicy; @@ -118,6 +141,10 @@ function buildSandboxBrowserResolvedConfig(params: { profiles: { [DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: { cdpPort: params.cdpPort, + cdpUrl: buildSandboxCdpUrl({ + cdpPort: params.cdpPort, + authToken: params.cdpAuthToken, + }), color: DEFAULT_OPENCLAW_BROWSER_COLOR, }, }, @@ -126,11 +153,25 @@ function buildSandboxBrowserResolvedConfig(params: { } async function ensureSandboxBrowserImage(image: string) { - const result = await execDocker(["image", "inspect", image], { - allowFailure: true, - }); + const result = await execDocker( + [ + "image", + "inspect", + "-f", + `{{ index .Config.Labels "${SANDBOX_BROWSER_IMAGE_CONTRACT_LABEL}" }}`, + image, + ], + { allowFailure: true }, + ); if (result.code === 0) { - return; + const contract = result.stdout.trim(); + if (contract === SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH) { + return; + } + const actual = contract && contract !== "" ? contract : "missing"; + throw new Error( + `Sandbox browser image ${image} is stale or incompatible (contract=${actual}, expected=${SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH}). Rebuild it with scripts/sandbox-browser-setup.sh.`, + ); } const stderr = result.stderr.trim(); if (isDockerDaemonUnavailable(stderr)) { @@ -210,12 +251,26 @@ export async function ensureSandboxBrowser(params: { let hashMismatch = false; const noVncEnabled = isNoVncEnabled(params.cfg.browser); let noVncPassword: string | undefined; + let cdpAuthToken: string | undefined; if (hasContainer) { if (noVncEnabled) { noVncPassword = (await readDockerContainerEnvVar(containerName, NOVNC_PASSWORD_ENV_KEY)) ?? undefined; } + cdpAuthToken = + (await readDockerContainerEnvVar(containerName, CDP_AUTH_TOKEN_ENV_KEY)) ?? undefined; + if (!cdpAuthToken) { + defaultRuntime.log( + `Removing stale sandbox browser container ${containerName} because it lacks the current CDP relay auth contract; it will be recreated.`, + ); + await execDocker(["rm", "-f", containerName], { allowFailure: true }); + hasContainer = false; + running = false; + } + } + + if (hasContainer) { const registry = await readBrowserRegistry(); const registryEntry = registry.entries.find((entry) => entry.containerName === containerName); currentHash = await readDockerContainerLabel(containerName, "openclaw.configHash"); @@ -254,39 +309,11 @@ export async function ensureSandboxBrowser(params: { if (noVncEnabled) { noVncPassword = generateNoVncPassword(); } + cdpAuthToken = crypto.randomBytes(24).toString("hex"); await ensureDockerNetwork(browserDockerCfg.network, { allowContainerNamespaceJoin: browserDockerCfg.dangerouslyAllowContainerNamespaceJoin === true, }); await ensureSandboxBrowserImage(browserImage); - // Derive effective CDP source range: explicit config > Docker network gateway > fail-closed. - // Only IPv4 gateways are usable for auto-derivation because the CDP relay - // binds on 0.0.0.0 (IPv4); an IPv6 CIDR would cause an address-family mismatch. - let effectiveCdpSourceRange = cdpSourceRange; - if (!effectiveCdpSourceRange) { - // Only auto-derive from gateway for bridge-style networks where inbound - // CDP traffic reliably comes from the Docker gateway IP. Non-bridge drivers - // (macvlan, ipvlan, overlay, etc.) may route traffic from other source IPs, - // so they require explicit cdpSourceRange config. - const driver = await readDockerNetworkDriver(browserDockerCfg.network); - const isBridgeLike = !driver || driver === "bridge"; - if (isBridgeLike) { - const gateway = await readDockerNetworkGateway(browserDockerCfg.network); - if (gateway && !gateway.includes(":")) { - effectiveCdpSourceRange = `${gateway}/32`; - } - } - } - // network="none" has no IPAM gateway by design and no peer container risk; - // use loopback range so the socat CDP relay still starts. - if (!effectiveCdpSourceRange && browserDockerCfg.network.trim().toLowerCase() === "none") { - effectiveCdpSourceRange = "127.0.0.1/32"; - } - if (!effectiveCdpSourceRange) { - throw new Error( - `Cannot derive CDP source range for sandbox browser on network "${browserDockerCfg.network}". ` + - `Set agents.defaults.sandbox.browser.cdpSourceRange explicitly.`, - ); - } const args = buildSandboxCreateArgs({ name: containerName, cfg: browserDockerCfg, @@ -318,12 +345,13 @@ export async function ensureSandboxBrowser(params: { args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); args.push("-e", `OPENCLAW_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`); args.push("-e", `OPENCLAW_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); + args.push("-e", `${CDP_AUTH_TOKEN_ENV_KEY}=${cdpAuthToken}`); args.push( "-e", `OPENCLAW_BROWSER_AUTO_START_TIMEOUT_MS=${params.cfg.browser.autoStartTimeoutMs}`, ); - if (effectiveCdpSourceRange) { - args.push("-e", `${CDP_SOURCE_RANGE_ENV_KEY}=${effectiveCdpSourceRange}`); + if (cdpSourceRange) { + args.push("-e", `${CDP_SOURCE_RANGE_ENV_KEY}=${cdpSourceRange}`); } args.push("-e", `OPENCLAW_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`); args.push("-e", `OPENCLAW_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`); @@ -342,6 +370,10 @@ export async function ensureSandboxBrowser(params: { if (!mappedCdp) { throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`); } + if (!cdpAuthToken) { + throw new Error(`Failed to resolve CDP relay auth for ${containerName}.`); + } + const cdpUrl = buildSandboxCdpUrl({ cdpPort: mappedCdp, authToken: cdpAuthToken }); const mappedNoVnc = noVncEnabled ? await readDockerPort(containerName, params.cfg.browser.noVncPort) @@ -368,7 +400,10 @@ export async function ensureSandboxBrowser(params: { } const shouldReuse = - existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; + existing && + existing.containerName === containerName && + existingProfile?.cdpPort === mappedCdp && + existingProfile?.cdpUrl === cdpUrl; const policyMatches = !existing || isSameSsrFPolicy(existing.bridge.state.resolved.ssrfPolicy, params.ssrfPolicy); const authMatches = @@ -405,6 +440,7 @@ export async function ensureSandboxBrowser(params: { } const ok = await waitForSandboxCdp({ cdpPort: mappedCdp, + authToken: cdpAuthToken, timeoutMs: params.cfg.browser.autoStartTimeoutMs, }); if (!ok) { @@ -420,6 +456,7 @@ export async function ensureSandboxBrowser(params: { resolved: buildSandboxBrowserResolvedConfig({ controlPort: 0, cdpPort: mappedCdp, + cdpAuthToken, headless: params.cfg.browser.headless, evaluateEnabled: desiredEvaluateEnabled, ssrfPolicy: params.ssrfPolicy, diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 1c2e2c752536..c5eda7e8deaf 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -39,7 +39,8 @@ export const DEFAULT_TOOL_DENY = [ export const DEFAULT_SANDBOX_BROWSER_IMAGE = "openclaw-sandbox-browser:bookworm-slim"; export const DEFAULT_SANDBOX_COMMON_IMAGE = "openclaw-sandbox-common:bookworm-slim"; -export const SANDBOX_BROWSER_SECURITY_HASH_EPOCH = "2026-04-05-cdp-source-range"; +export const SANDBOX_BROWSER_SECURITY_HASH_EPOCH = "2026-05-12-cdp-relay-auth"; +export const SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH = "2026-05-12-cdp-relay-auth"; export const DEFAULT_SANDBOX_BROWSER_PREFIX = "openclaw-sbx-browser-"; export const DEFAULT_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser";