diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d889d57b7..5cbecea3879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway: parse `X-Forwarded-For` with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc. - Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and add security-audit checks for stale/missing sandbox browser Docker hash labels. This ships in the next npm release. Thanks @TerminalsandCoffee and @vincentkoc. - Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. This ships in the next npm release. Thanks @TerminalsandCoffee for reporting. +- Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (`openclaw-sandbox-browser`), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in `openclaw security --audit` when browser sandboxing runs on bridge without source-range limits. This ships in the next npm release. Thanks @TerminalsandCoffee for reporting. - Auto-reply/Tools: forward `senderIsOwner` through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj. - Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow. - Memory/QMD: respect per-agent `memorySearch.enabled=false` during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (`search`/`vsearch`/`query`) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip `qmd embed` in BM25-only `search` mode (including `memory index --force`), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel. diff --git a/docs/cli/security.md b/docs/cli/security.md index 1e97225c802..9bfa39b1358 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -28,6 +28,7 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy. +It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index e470fbc6255..3e2417971bb 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -930,7 +930,9 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway browser: { enabled: false, image: "openclaw-sandbox-browser:bookworm-slim", + network: "openclaw-sandbox-browser", cdpPort: 9222, + cdpSourceRange: "172.21.0.1/32", vncPort: 5900, noVncPort: 6080, headless: false, @@ -995,6 +997,8 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived token URL (instead of exposing the password in the shared URL). - `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser. +- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity. +- `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`). - `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 25c5efa6793..6d51f573990 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -22,6 +22,9 @@ and process access when the model does something dumb. - Optional sandboxed browser (`agents.defaults.sandbox.browser`). - By default, the sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it. Configure via `agents.defaults.sandbox.browser.autoStart` and `agents.defaults.sandbox.browser.autoStartTimeoutMs`. + - By default, sandbox browser containers use a dedicated Docker network (`openclaw-sandbox-browser`) instead of the global `bridge` network. + Configure with `agents.defaults.sandbox.browser.network`. + - Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress with a CIDR allowlist (for example `172.21.0.1/32`). - noVNC observer access is password-protected by default; OpenClaw emits a short-lived token URL that resolves to the observer session. - `agents.defaults.sandbox.browser.allowHostControl` lets sandboxed sessions target the host browser explicitly. - Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`. diff --git a/docs/install/docker.md b/docs/install/docker.md index 92fe1fc4751..8826192c1c1 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -495,6 +495,8 @@ Notes: - Headful (Xvfb) reduces bot blocking vs headless. - Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`. - No full desktop environment (GNOME) is needed; Xvfb provides the display. +- Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`. +- Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`). - noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL instead of sharing the raw password in the URL. Use config: diff --git a/scripts/sandbox-browser-entrypoint.sh b/scripts/sandbox-browser-entrypoint.sh index ce74d44f5c4..076643facd9 100755 --- a/scripts/sandbox-browser-entrypoint.sh +++ b/scripts/sandbox-browser-entrypoint.sh @@ -7,6 +7,7 @@ export XDG_CONFIG_HOME="${HOME}/.config" export XDG_CACHE_HOME="${HOME}/.cache" CDP_PORT="${OPENCLAW_BROWSER_CDP_PORT:-${CLAWDBOT_BROWSER_CDP_PORT:-9222}}" +CDP_SOURCE_RANGE="${OPENCLAW_BROWSER_CDP_SOURCE_RANGE:-${CLAWDBOT_BROWSER_CDP_SOURCE_RANGE:-}}" VNC_PORT="${OPENCLAW_BROWSER_VNC_PORT:-${CLAWDBOT_BROWSER_VNC_PORT:-5900}}" NOVNC_PORT="${OPENCLAW_BROWSER_NOVNC_PORT:-${CLAWDBOT_BROWSER_NOVNC_PORT:-6080}}" ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:-1}}" @@ -63,9 +64,11 @@ for _ in $(seq 1 50); do sleep 0.1 done -socat \ - TCP-LISTEN:"${CDP_PORT}",fork,reuseaddr,bind=0.0.0.0 \ - TCP:127.0.0.1:"${CHROME_CDP_PORT}" & +SOCAT_LISTEN_ADDR="TCP-LISTEN:${CDP_PORT},fork,reuseaddr,bind=0.0.0.0" +if [[ -n "${CDP_SOURCE_RANGE}" ]]; then + SOCAT_LISTEN_ADDR="${SOCAT_LISTEN_ADDR},range=${CDP_SOURCE_RANGE}" +fi +socat "${SOCAT_LISTEN_ADDR}" "TCP:127.0.0.1:${CHROME_CDP_PORT}" & if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then # VNC auth passwords are max 8 chars; use a random default when not provided. diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 0b0e4de0534..e4b16880b81 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -38,6 +38,7 @@ import { isToolAllowed } from "./tool-policy.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000; +const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE"; async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }): Promise { const deadline = Date.now() + Math.max(0, params.timeoutMs); @@ -106,6 +107,23 @@ async function ensureSandboxBrowserImage(image: string) { ); } +async function ensureDockerNetwork(network: string) { + const normalized = network.trim().toLowerCase(); + if ( + !normalized || + normalized === "bridge" || + normalized === "none" || + normalized.startsWith("container:") + ) { + return; + } + const inspect = await execDocker(["network", "inspect", network], { allowFailure: true }); + if (inspect.code === 0) { + return; + } + await execDocker(["network", "create", "--driver", "bridge", network]); +} + export async function ensureSandboxBrowser(params: { scopeKey: string; workspaceDir: string; @@ -126,6 +144,7 @@ export async function ensureSandboxBrowser(params: { const containerName = name.slice(0, 63); const state = await dockerContainerState(containerName); const browserImage = params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE; + const cdpSourceRange = params.cfg.browser.cdpSourceRange?.trim() || undefined; const browserDockerCfg = resolveSandboxBrowserDockerCreateConfig({ docker: params.cfg.docker, browser: { ...params.cfg.browser, image: browserImage }, @@ -138,6 +157,7 @@ export async function ensureSandboxBrowser(params: { noVncPort: params.cfg.browser.noVncPort, headless: params.cfg.browser.headless, enableNoVnc: params.cfg.browser.enableNoVnc, + cdpSourceRange, }, securityEpoch: SANDBOX_BROWSER_SECURITY_HASH_EPOCH, workspaceAccess: params.cfg.workspaceAccess, @@ -196,6 +216,7 @@ export async function ensureSandboxBrowser(params: { if (noVncEnabled) { noVncPassword = generateNoVncPassword(); } + await ensureDockerNetwork(browserDockerCfg.network); await ensureSandboxBrowserImage(browserImage); const args = buildSandboxCreateArgs({ name: containerName, @@ -226,6 +247,9 @@ 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}`); + 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}`); if (noVncEnabled && noVncPassword) { diff --git a/src/agents/sandbox/config-hash.test.ts b/src/agents/sandbox/config-hash.test.ts index 852bfea1d30..a4ea2bbb1c5 100644 --- a/src/agents/sandbox/config-hash.test.ts +++ b/src/agents/sandbox/config-hash.test.ts @@ -110,6 +110,7 @@ describe("computeSandboxBrowserConfigHash", () => { const shared = { browser: { cdpPort: 9222, + cdpSourceRange: undefined, vncPort: 5900, noVncPort: 6080, headless: false, @@ -140,6 +141,7 @@ describe("computeSandboxBrowserConfigHash", () => { docker: createDockerConfig(), browser: { cdpPort: 9222, + cdpSourceRange: undefined, vncPort: 5900, noVncPort: 6080, headless: false, @@ -159,4 +161,30 @@ describe("computeSandboxBrowserConfigHash", () => { }); expect(left).not.toBe(right); }); + + it("changes when cdp source range changes", () => { + const shared = { + docker: createDockerConfig(), + browser: { + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: false, + enableNoVnc: true, + }, + securityEpoch: "epoch-v1", + workspaceAccess: "rw" as const, + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + }; + const left = computeSandboxBrowserConfigHash({ + ...shared, + browser: { ...shared.browser, cdpSourceRange: "172.21.0.1/32" }, + }); + const right = computeSandboxBrowserConfigHash({ + ...shared, + browser: { ...shared.browser, cdpSourceRange: "172.22.0.1/32" }, + }); + expect(left).not.toBe(right); + }); }); diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index e77652aab2e..c5354c24097 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -12,7 +12,7 @@ type SandboxBrowserHashInput = { docker: SandboxDockerConfig; browser: Pick< SandboxBrowserConfig, - "cdpPort" | "vncPort" | "noVncPort" | "headless" | "enableNoVnc" + "cdpPort" | "cdpSourceRange" | "vncPort" | "noVncPort" | "headless" | "enableNoVnc" >; securityEpoch: string; workspaceAccess: SandboxWorkspaceAccess; diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index f2735f29f1f..0fcb50999e4 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -4,6 +4,7 @@ import { DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS, DEFAULT_SANDBOX_BROWSER_CDP_PORT, DEFAULT_SANDBOX_BROWSER_IMAGE, + DEFAULT_SANDBOX_BROWSER_NETWORK, DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, DEFAULT_SANDBOX_BROWSER_PREFIX, DEFAULT_SANDBOX_BROWSER_VNC_PORT, @@ -27,10 +28,11 @@ export function resolveSandboxBrowserDockerCreateConfig(params: { docker: SandboxDockerConfig; browser: SandboxBrowserConfig; }): SandboxDockerConfig { + const browserNetwork = params.browser.network.trim(); const base: SandboxDockerConfig = { ...params.docker, // Browser container needs network access for Chrome, downloads, etc. - network: "bridge", + network: browserNetwork || DEFAULT_SANDBOX_BROWSER_NETWORK, // For hashing and consistency, treat browser image as the docker image even though we // pass it separately as the final `docker create` argument. image: params.browser.image, @@ -113,7 +115,9 @@ export function resolveSandboxBrowserConfig(params: { agentBrowser?.containerPrefix ?? globalBrowser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX, + network: agentBrowser?.network ?? globalBrowser?.network ?? DEFAULT_SANDBOX_BROWSER_NETWORK, cdpPort: agentBrowser?.cdpPort ?? globalBrowser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT, + cdpSourceRange: agentBrowser?.cdpSourceRange ?? globalBrowser?.cdpSourceRange, vncPort: agentBrowser?.vncPort ?? globalBrowser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT, noVncPort: agentBrowser?.noVncPort ?? globalBrowser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index f4172b2e0dd..6389ed4196e 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -41,6 +41,7 @@ export const DEFAULT_SANDBOX_COMMON_IMAGE = "openclaw-sandbox-common:bookworm-sl export const SANDBOX_BROWSER_SECURITY_HASH_EPOCH = "2026-02-21-novnc-auth-default"; export const DEFAULT_SANDBOX_BROWSER_PREFIX = "openclaw-sbx-browser-"; +export const DEFAULT_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser"; export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; export const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 5bde8562f2e..f64ee31bd92 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -106,6 +106,7 @@ function createSandboxConfig(dns: string[]): SandboxConfig { enabled: false, image: "openclaw-browser:test", containerPrefix: "oc-browser-", + network: "openclaw-sandbox-browser", cdpPort: 9222, vncPort: 5900, noVncPort: 6080, diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index f667941e39d..4ccfd691cfb 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -32,7 +32,9 @@ export type SandboxBrowserConfig = { enabled: boolean; image: string; containerPrefix: string; + network: string; cdpPort: number; + cdpSourceRange?: string; vncPort: number; noVncPort: number; headless: boolean; diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 7add1d3c293..d7c3cd286a0 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -177,4 +177,46 @@ describe("sandbox browser binds config", () => { }); expect(resolved.binds).toBeUndefined(); }); + + it("defaults browser network to dedicated sandbox network", () => { + const resolved = resolveSandboxBrowserConfig({ + scope: "agent", + globalBrowser: {}, + agentBrowser: {}, + }); + expect(resolved.network).toBe("openclaw-sandbox-browser"); + }); + + it("prefers agent browser network over global browser network", () => { + const resolved = resolveSandboxBrowserConfig({ + scope: "agent", + globalBrowser: { network: "openclaw-sandbox-browser-global" }, + agentBrowser: { network: "openclaw-sandbox-browser-agent" }, + }); + expect(resolved.network).toBe("openclaw-sandbox-browser-agent"); + }); + + it("merges cdpSourceRange with agent override", () => { + const resolved = resolveSandboxBrowserConfig({ + scope: "agent", + globalBrowser: { cdpSourceRange: "172.21.0.1/32" }, + agentBrowser: { cdpSourceRange: "172.22.0.1/32" }, + }); + expect(resolved.cdpSourceRange).toBe("172.22.0.1/32"); + }); + + it("rejects host network mode in sandbox.browser config", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + browser: { + network: "host", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e96e6f149f0..6e3b658b917 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -26,6 +26,13 @@ export const FIELD_HELP: Record = { "gateway.auth.token": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", + "agents.defaults.sandbox.browser.network": + "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", + "agents.list[].sandbox.browser.network": "Per-agent override for sandbox browser Docker network.", + "agents.defaults.sandbox.browser.cdpSourceRange": + "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", + "agents.list[].sandbox.browser.cdpSourceRange": + "Per-agent override for CDP source CIDR allowlist.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /openclaw).", "gateway.controlUi.root": diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 4f05df8d152..dc8d3a791c7 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -48,7 +48,11 @@ export type SandboxBrowserSettings = { enabled?: boolean; image?: string; containerPrefix?: string; + /** Docker network for sandbox browser containers (default: openclaw-sandbox-browser). */ + network?: string; cdpPort?: number; + /** Optional CIDR allowlist for CDP ingress at the container edge (for example: 172.21.0.1/32). */ + cdpSourceRange?: string; vncPort?: number; noVncPort?: number; headless?: boolean; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 78b61b9a078..6e0a92cfd68 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -185,7 +185,9 @@ export const SandboxBrowserSchema = z enabled: z.boolean().optional(), image: z.string().optional(), containerPrefix: z.string().optional(), + network: z.string().optional(), cdpPort: z.number().int().positive().optional(), + cdpSourceRange: z.string().optional(), vncPort: z.number().int().positive().optional(), noVncPort: z.number().int().positive().optional(), headless: z.boolean().optional(), @@ -195,6 +197,16 @@ export const SandboxBrowserSchema = z autoStartTimeoutMs: z.number().int().positive().optional(), binds: z.array(z.string()).optional(), }) + .superRefine((data, ctx) => { + if (data.network?.trim().toLowerCase() === "host") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["network"], + message: + 'Sandbox security: browser network mode "host" is blocked. Use "bridge" or a custom bridge network instead.', + }); + } + }) .strict() .optional(); diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 0bf76a4ad97..3ecfe21b596 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -714,6 +714,45 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu } } + const browserExposurePaths: string[] = []; + const defaultBrowser = resolveSandboxConfigForAgent(cfg).browser; + if ( + defaultBrowser.enabled && + defaultBrowser.network.trim().toLowerCase() === "bridge" && + !defaultBrowser.cdpSourceRange?.trim() + ) { + browserExposurePaths.push("agents.defaults.sandbox.browser"); + } + for (const entry of agents) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + const browser = resolveSandboxConfigForAgent(cfg, entry.id).browser; + if (!browser.enabled) { + continue; + } + if (browser.network.trim().toLowerCase() !== "bridge") { + continue; + } + if (browser.cdpSourceRange?.trim()) { + continue; + } + browserExposurePaths.push(`agents.list.${entry.id}.sandbox.browser`); + } + if (browserExposurePaths.length > 0) { + findings.push({ + checkId: "sandbox.browser_cdp_bridge_unrestricted", + severity: "warn", + title: "Sandbox browser CDP may be reachable by peer containers", + detail: + "These sandbox browser configs use Docker bridge networking with no CDP source restriction:\n" + + browserExposurePaths.map((entry) => `- ${entry}`).join("\n"), + remediation: + "Set sandbox.browser.network to a dedicated bridge network (recommended default: openclaw-sandbox-browser), " + + "or set sandbox.browser.cdpSourceRange (for example 172.21.0.1/32) to restrict container-edge CDP ingress.", + }); + } + return findings; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 65f71b7edcd..b4b905df41d 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -703,6 +703,47 @@ describe("security audit", () => { ); }); + it("warns when sandbox browser uses bridge network without cdpSourceRange", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { + enabled: true, + network: "bridge", + }, + }, + }, + }, + }; + + const res = await audit(cfg); + const finding = res.findings.find( + (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain("agents.defaults.sandbox.browser"); + }); + + it("does not warn when sandbox browser uses dedicated default network", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { + enabled: true, + }, + }, + }, + }, + }; + + const res = await audit(cfg); + expect(hasFinding(res, "sandbox.browser_cdp_bridge_unrestricted")).toBe(false); + }); + it("flags ineffective gateway.nodes.denyCommands entries", async () => { const cfg: OpenClawConfig = { gateway: {