feat(sandbox): block container namespace joins by default

This commit is contained in:
Peter Steinberger
2026-02-24 23:19:48 +00:00
parent ccbeb332e0
commit 14b6eea6e3
17 changed files with 253 additions and 18 deletions

View File

@@ -181,6 +181,12 @@ describe("buildSandboxCreateArgs", () => {
cfg: createSandboxConfig({ network: "host" }),
expected: /network mode "host" is blocked/,
},
{
name: "network container namespace join",
containerName: "openclaw-sbx-container-network",
cfg: createSandboxConfig({ network: "container:peer" }),
expected: /network mode "container:peer" is blocked by default/,
},
{
name: "seccomp unconfined",
containerName: "openclaw-sbx-seccomp",
@@ -271,4 +277,18 @@ describe("buildSandboxCreateArgs", () => {
});
expect(args).toEqual(expect.arrayContaining(["-v", "/tmp/override:/workspace:rw"]));
});
it("allows container namespace join with explicit dangerous override", () => {
const cfg = createSandboxConfig({
network: "container:peer",
dangerouslyAllowContainerNamespaceJoin: true,
});
const args = buildSandboxCreateArgs({
name: "openclaw-sbx-container-network-override",
cfg,
scopeKey: "main",
createdAtMs: 1700000000000,
});
expect(args).toEqual(expect.arrayContaining(["--network", "container:peer"]));
});
});

View File

@@ -36,6 +36,7 @@ import { readBrowserRegistry, updateBrowserRegistry } from "./registry.js";
import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js";
import { isToolAllowed } from "./tool-policy.js";
import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
import { validateNetworkMode } from "./validate-sandbox-security.js";
const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000;
const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE";
@@ -107,14 +108,15 @@ async function ensureSandboxBrowserImage(image: string) {
);
}
async function ensureDockerNetwork(network: string) {
async function ensureDockerNetwork(
network: string,
opts?: { allowContainerNamespaceJoin?: boolean },
) {
validateNetworkMode(network, {
allowContainerNamespaceJoin: opts?.allowContainerNamespaceJoin === true,
});
const normalized = network.trim().toLowerCase();
if (
!normalized ||
normalized === "bridge" ||
normalized === "none" ||
normalized.startsWith("container:")
) {
if (!normalized || normalized === "bridge" || normalized === "none") {
return;
}
const inspect = await execDocker(["network", "inspect", network], { allowFailure: true });
@@ -216,7 +218,9 @@ export async function ensureSandboxBrowser(params: {
if (noVncEnabled) {
noVncPassword = generateNoVncPassword();
}
await ensureDockerNetwork(browserDockerCfg.network);
await ensureDockerNetwork(browserDockerCfg.network, {
allowContainerNamespaceJoin: browserDockerCfg.dangerouslyAllowContainerNamespaceJoin === true,
});
await ensureSandboxBrowserImage(browserImage);
const args = buildSandboxCreateArgs({
name: containerName,

View File

@@ -95,6 +95,15 @@ export function resolveSandboxDockerConfig(params: {
dns: agentDocker?.dns ?? globalDocker?.dns,
extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts,
binds: binds.length ? binds : undefined,
dangerouslyAllowReservedContainerTargets:
agentDocker?.dangerouslyAllowReservedContainerTargets ??
globalDocker?.dangerouslyAllowReservedContainerTargets,
dangerouslyAllowExternalBindSources:
agentDocker?.dangerouslyAllowExternalBindSources ??
globalDocker?.dangerouslyAllowExternalBindSources,
dangerouslyAllowContainerNamespaceJoin:
agentDocker?.dangerouslyAllowContainerNamespaceJoin ??
globalDocker?.dangerouslyAllowContainerNamespaceJoin,
};
}

View File

@@ -267,6 +267,7 @@ export function buildSandboxCreateArgs(params: {
bindSourceRoots?: string[];
allowSourcesOutsideAllowedRoots?: boolean;
allowReservedContainerTargets?: boolean;
allowContainerNamespaceJoin?: boolean;
}) {
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
validateSandboxSecurity({
@@ -278,6 +279,9 @@ export function buildSandboxCreateArgs(params: {
allowReservedContainerTargets:
params.allowReservedContainerTargets ??
params.cfg.dangerouslyAllowReservedContainerTargets === true,
dangerouslyAllowContainerNamespaceJoin:
params.allowContainerNamespaceJoin ??
params.cfg.dangerouslyAllowContainerNamespaceJoin === true,
});
const createdAtMs = params.createdAtMs ?? Date.now();

View File

@@ -222,6 +222,30 @@ describe("validateNetworkMode", () => {
expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected);
}
});
it("blocks container namespace joins by default", () => {
const cases = [
{
mode: "container:abc123",
expected: /network mode "container:abc123" is blocked by default/,
},
{
mode: "CONTAINER:ABC123",
expected: /network mode "CONTAINER:ABC123" is blocked by default/,
},
] as const;
for (const testCase of cases) {
expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected);
}
});
it("allows container namespace joins with explicit dangerous override", () => {
expect(() =>
validateNetworkMode("container:abc123", {
allowContainerNamespaceJoin: true,
}),
).not.toThrow();
});
});
describe("validateSeccompProfile", () => {

View File

@@ -42,6 +42,10 @@ export type ValidateBindMountsOptions = {
allowReservedContainerTargets?: boolean;
};
export type ValidateNetworkModeOptions = {
allowContainerNamespaceJoin?: boolean;
};
export type BlockedBindReason =
| { kind: "targets"; blockedPath: string }
| { kind: "covers"; blockedPath: string }
@@ -276,14 +280,30 @@ export function validateBindMounts(
}
}
export function validateNetworkMode(network: string | undefined): void {
if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) {
export function validateNetworkMode(
network: string | undefined,
options?: ValidateNetworkModeOptions,
): void {
const normalized = network?.trim().toLowerCase();
if (!normalized) {
return;
}
if (BLOCKED_NETWORK_MODES.has(normalized)) {
throw new Error(
`Sandbox security: network mode "${network}" is blocked. ` +
'Network "host" mode bypasses container network isolation. ' +
'Use "bridge" or "none" instead.',
);
}
if (normalized.startsWith("container:") && options?.allowContainerNamespaceJoin !== true) {
throw new Error(
`Sandbox security: network mode "${network}" is blocked by default. ` +
'Network "container:*" joins another container namespace and bypasses sandbox network isolation. ' +
"Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.",
);
}
}
export function validateSeccompProfile(profile: string | undefined): void {
@@ -312,10 +332,13 @@ export function validateSandboxSecurity(
network?: string;
seccompProfile?: string;
apparmorProfile?: string;
dangerouslyAllowContainerNamespaceJoin?: boolean;
} & ValidateBindMountsOptions,
): void {
validateBindMounts(cfg.binds, cfg);
validateNetworkMode(cfg.network);
validateNetworkMode(cfg.network, {
allowContainerNamespaceJoin: cfg.dangerouslyAllowContainerNamespaceJoin === true,
});
validateSeccompProfile(cfg.seccompProfile);
validateApparmorProfile(cfg.apparmorProfile);
}

View File

@@ -53,6 +53,37 @@ describe("sandbox docker config", () => {
expect(res.ok).toBe(false);
});
it("rejects container namespace join by default", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
network: "container:peer",
},
},
},
},
});
expect(res.ok).toBe(false);
});
it("allows container namespace join with explicit dangerous override", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
network: "container:peer",
dangerouslyAllowContainerNamespaceJoin: true,
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects seccomp unconfined via Zod schema validation", () => {
const res = validateConfigObject({
agents: {
@@ -219,4 +250,37 @@ describe("sandbox browser binds config", () => {
});
expect(res.ok).toBe(false);
});
it("rejects container namespace join in sandbox.browser config by default", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
browser: {
network: "container:peer",
},
},
},
},
});
expect(res.ok).toBe(false);
});
it("allows container namespace join in sandbox.browser config with explicit dangerous override", () => {
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
dangerouslyAllowContainerNamespaceJoin: true,
},
browser: {
network: "container:peer",
},
},
},
},
});
expect(res.ok).toBe(true);
});
});

View File

@@ -52,6 +52,11 @@ export type SandboxDockerSettings = {
* (workspace + agent workspace roots).
*/
dangerouslyAllowExternalBindSources?: boolean;
/**
* Dangerous override: allow Docker `network: "container:<id>"` namespace joins.
* Default behavior blocks container namespace joins to preserve sandbox isolation.
*/
dangerouslyAllowContainerNamespaceJoin?: boolean;
};
export type SandboxBrowserSettings = {

View File

@@ -126,6 +126,7 @@ export const SandboxDockerSchema = z
binds: z.array(z.string()).optional(),
dangerouslyAllowReservedContainerTargets: z.boolean().optional(),
dangerouslyAllowExternalBindSources: z.boolean().optional(),
dangerouslyAllowContainerNamespaceJoin: z.boolean().optional(),
})
.strict()
.superRefine((data, ctx) => {
@@ -153,7 +154,8 @@ export const SandboxDockerSchema = z
}
}
}
if (data.network?.trim().toLowerCase() === "host") {
const network = data.network?.trim().toLowerCase();
if (network === "host") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["network"],
@@ -161,6 +163,15 @@ export const SandboxDockerSchema = z
'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.',
});
}
if (network?.startsWith("container:") && data.dangerouslyAllowContainerNamespaceJoin !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["network"],
message:
'Sandbox security: network mode "container:*" is blocked by default. ' +
"Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.",
});
}
if (data.seccompProfile?.trim().toLowerCase() === "unconfined") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -464,6 +475,21 @@ export const AgentSandboxSchema = z
prune: SandboxPruneSchema,
})
.strict()
.superRefine((data, ctx) => {
const browserNetwork = data.browser?.network?.trim().toLowerCase();
if (
browserNetwork?.startsWith("container:") &&
data.docker?.dangerouslyAllowContainerNamespaceJoin !== true
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["browser", "network"],
message:
'Sandbox security: browser network mode "container:*" is blocked by default. ' +
"Set sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.",
});
}
})
.optional();
const CommonToolPolicyFields = {

View File

@@ -830,13 +830,21 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu
}
const network = typeof docker.network === "string" ? docker.network : undefined;
if (network && network.trim().toLowerCase() === "host") {
const normalizedNetwork = network?.trim().toLowerCase();
if (normalizedNetwork === "host" || normalizedNetwork?.startsWith("container:")) {
const modeLabel = normalizedNetwork === "host" ? '"host"' : `"${network}"`;
const detail =
normalizedNetwork === "host"
? `${source}.network is "host" which bypasses container network isolation entirely.`
: `${source}.network is ${modeLabel} which joins another container namespace and can bypass sandbox network isolation.`;
findings.push({
checkId: "sandbox.dangerous_network_mode",
severity: "critical",
title: "Network host mode in sandbox config",
detail: `${source}.network is "host" which bypasses container network isolation entirely.`,
remediation: `Set ${source}.network to "bridge" or "none".`,
title: "Dangerous network mode in sandbox config",
detail,
remediation:
`Set ${source}.network to "bridge", "none", or a custom bridge network name.` +
` Use ${source}.dangerouslyAllowContainerNamespaceJoin=true only as a break-glass override when you fully trust this runtime.`,
});
}

View File

@@ -855,6 +855,31 @@ describe("security audit", () => {
);
});
it("flags container namespace join network mode in sandbox config", async () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "all",
docker: {
network: "container:peer",
},
},
},
},
};
const res = await audit(cfg);
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "sandbox.dangerous_network_mode",
severity: "critical",
title: "Dangerous network mode in sandbox config",
}),
]),
);
});
it("checks sandbox browser bridge-network restrictions", async () => {
const cases: Array<{
name: string;