mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(security): harden sandbox browser network defaults
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user