mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
ed2a451363
commit
b6a94d4ef9
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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 !== "<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)) {
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user