fix(security): harden sandbox browser network defaults

This commit is contained in:
Peter Steinberger
2026-02-21 14:01:40 +01:00
parent cf82614259
commit f48698a50b
19 changed files with 224 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<boolean> {
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,13 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

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

View File

@@ -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();

View File

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

View File

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