Require auth for sandbox browser CDP relay [AI] (#81002)

* fix: require auth for sandbox browser cdp relay

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing claude review

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-12 19:37:32 +05:30
committed by GitHub
parent ed2a451363
commit b6a94d4ef9
12 changed files with 388 additions and 105 deletions

View File

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

View File

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

View File

@@ -187,6 +187,20 @@ export function getHeadersWithAuth(url: string, headers: Record<string, string>
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<string, string>) || {});
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,

View File

@@ -56,7 +56,7 @@ function readFirstReachabilityCall(
return call;
}
function createManagedProfileState() {
function createManagedProfileState(profileOverrides?: Record<string, unknown>) {
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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> },
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: "<no value>\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({
await ensureTestSandboxBrowser({
scopeKey: "session:test",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: buildConfig(false),
}),
).rejects.toThrow(/Cannot derive CDP source range/);
});
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/);
expect(dockerMocks.execDocker).toHaveBeenCalledWith(
["rm", "-f", "openclaw-sbx-browser-session-test-0661d10a"],
{ allowFailure: true },
);
expect(findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create")).toBeDefined();
});
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,
);
});
});

View File

@@ -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<boolean> {
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<boolean> {
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,12 +153,26 @@ 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) {
const contract = result.stdout.trim();
if (contract === SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH) {
return;
}
const actual = contract && contract !== "<no value>" ? 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)) {
throw new Error(formatDockerDaemonUnavailableError(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,

View File

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