mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:54:33 +00:00
fix(security): harden sandbox docker config validation
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
|
||||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe("buildSandboxCreateArgs", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits -v flags for custom binds", () => {
|
it("emits -v flags for safe custom binds", () => {
|
||||||
const cfg: SandboxDockerConfig = {
|
const cfg: SandboxDockerConfig = {
|
||||||
image: "openclaw-sandbox:bookworm-slim",
|
image: "openclaw-sandbox:bookworm-slim",
|
||||||
containerPrefix: "openclaw-sbx-",
|
containerPrefix: "openclaw-sbx-",
|
||||||
@@ -103,7 +103,7 @@ describe("buildSandboxCreateArgs", () => {
|
|||||||
tmpfs: [],
|
tmpfs: [],
|
||||||
network: "none",
|
network: "none",
|
||||||
capDrop: [],
|
capDrop: [],
|
||||||
binds: ["/home/user/source:/source:rw", "/var/run/docker.sock:/var/run/docker.sock"],
|
binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const args = buildSandboxCreateArgs({
|
const args = buildSandboxCreateArgs({
|
||||||
@@ -124,7 +124,116 @@ describe("buildSandboxCreateArgs", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(vFlags).toContain("/home/user/source:/source:rw");
|
expect(vFlags).toContain("/home/user/source:/source:rw");
|
||||||
expect(vFlags).toContain("/var/run/docker.sock:/var/run/docker.sock");
|
expect(vFlags).toContain("/var/data/myapp:/data:ro");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on dangerous bind mounts (Docker socket)", () => {
|
||||||
|
const cfg: SandboxDockerConfig = {
|
||||||
|
image: "openclaw-sandbox:bookworm-slim",
|
||||||
|
containerPrefix: "openclaw-sbx-",
|
||||||
|
workdir: "/workspace",
|
||||||
|
readOnlyRoot: false,
|
||||||
|
tmpfs: [],
|
||||||
|
network: "none",
|
||||||
|
capDrop: [],
|
||||||
|
binds: ["/var/run/docker.sock:/var/run/docker.sock"],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
buildSandboxCreateArgs({
|
||||||
|
name: "openclaw-sbx-dangerous",
|
||||||
|
cfg,
|
||||||
|
scopeKey: "main",
|
||||||
|
createdAtMs: 1700000000000,
|
||||||
|
}),
|
||||||
|
).toThrow(/blocked path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on dangerous bind mounts (parent path)", () => {
|
||||||
|
const cfg: SandboxDockerConfig = {
|
||||||
|
image: "openclaw-sandbox:bookworm-slim",
|
||||||
|
containerPrefix: "openclaw-sbx-",
|
||||||
|
workdir: "/workspace",
|
||||||
|
readOnlyRoot: false,
|
||||||
|
tmpfs: [],
|
||||||
|
network: "none",
|
||||||
|
capDrop: [],
|
||||||
|
binds: ["/run:/run"],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
buildSandboxCreateArgs({
|
||||||
|
name: "openclaw-sbx-dangerous-parent",
|
||||||
|
cfg,
|
||||||
|
scopeKey: "main",
|
||||||
|
createdAtMs: 1700000000000,
|
||||||
|
}),
|
||||||
|
).toThrow(/blocked path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on network host mode", () => {
|
||||||
|
const cfg: SandboxDockerConfig = {
|
||||||
|
image: "openclaw-sandbox:bookworm-slim",
|
||||||
|
containerPrefix: "openclaw-sbx-",
|
||||||
|
workdir: "/workspace",
|
||||||
|
readOnlyRoot: false,
|
||||||
|
tmpfs: [],
|
||||||
|
network: "host",
|
||||||
|
capDrop: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
buildSandboxCreateArgs({
|
||||||
|
name: "openclaw-sbx-host",
|
||||||
|
cfg,
|
||||||
|
scopeKey: "main",
|
||||||
|
createdAtMs: 1700000000000,
|
||||||
|
}),
|
||||||
|
).toThrow(/network mode "host" is blocked/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on seccomp unconfined", () => {
|
||||||
|
const cfg: SandboxDockerConfig = {
|
||||||
|
image: "openclaw-sandbox:bookworm-slim",
|
||||||
|
containerPrefix: "openclaw-sbx-",
|
||||||
|
workdir: "/workspace",
|
||||||
|
readOnlyRoot: false,
|
||||||
|
tmpfs: [],
|
||||||
|
network: "none",
|
||||||
|
capDrop: [],
|
||||||
|
seccompProfile: "unconfined",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
buildSandboxCreateArgs({
|
||||||
|
name: "openclaw-sbx-seccomp",
|
||||||
|
cfg,
|
||||||
|
scopeKey: "main",
|
||||||
|
createdAtMs: 1700000000000,
|
||||||
|
}),
|
||||||
|
).toThrow(/seccomp profile "unconfined" is blocked/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on apparmor unconfined", () => {
|
||||||
|
const cfg: SandboxDockerConfig = {
|
||||||
|
image: "openclaw-sandbox:bookworm-slim",
|
||||||
|
containerPrefix: "openclaw-sbx-",
|
||||||
|
workdir: "/workspace",
|
||||||
|
readOnlyRoot: false,
|
||||||
|
tmpfs: [],
|
||||||
|
network: "none",
|
||||||
|
capDrop: [],
|
||||||
|
apparmorProfile: "unconfined",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
buildSandboxCreateArgs({
|
||||||
|
name: "openclaw-sbx-apparmor",
|
||||||
|
cfg,
|
||||||
|
scopeKey: "main",
|
||||||
|
createdAtMs: 1700000000000,
|
||||||
|
}),
|
||||||
|
).toThrow(/apparmor profile "unconfined" is blocked/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits -v flags when binds is empty or undefined", () => {
|
it("omits -v flags when binds is empty or undefined", () => {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ import { computeSandboxConfigHash } from "./config-hash.js";
|
|||||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||||
import { readRegistry, updateRegistry } from "./registry.js";
|
import { readRegistry, updateRegistry } from "./registry.js";
|
||||||
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
||||||
|
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
|
||||||
|
|
||||||
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@@ -240,6 +241,9 @@ export function buildSandboxCreateArgs(params: {
|
|||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
configHash?: string;
|
configHash?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
|
||||||
|
validateSandboxSecurity(params.cfg);
|
||||||
|
|
||||||
const createdAtMs = params.createdAtMs ?? Date.now();
|
const createdAtMs = params.createdAtMs ?? Date.now();
|
||||||
const args = ["create", "--name", params.name];
|
const args = ["create", "--name", params.name];
|
||||||
args.push("--label", "openclaw.sandbox=1");
|
args.push("--label", "openclaw.sandbox=1");
|
||||||
|
|||||||
146
src/agents/sandbox/validate-sandbox-security.test.ts
Normal file
146
src/agents/sandbox/validate-sandbox-security.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { mkdtempSync, symlinkSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getBlockedBindReasonStringOnly,
|
||||||
|
validateBindMounts,
|
||||||
|
validateNetworkMode,
|
||||||
|
validateSeccompProfile,
|
||||||
|
validateApparmorProfile,
|
||||||
|
validateSandboxSecurity,
|
||||||
|
} from "./validate-sandbox-security.js";
|
||||||
|
|
||||||
|
describe("getBlockedBindReasonStringOnly", () => {
|
||||||
|
it("blocks ancestor mounts that would expose the Docker socket", () => {
|
||||||
|
expect(getBlockedBindReasonStringOnly("/run:/run")).toEqual(
|
||||||
|
expect.objectContaining({ kind: "covers" }),
|
||||||
|
);
|
||||||
|
expect(getBlockedBindReasonStringOnly("/var/run:/var/run:ro")).toEqual(
|
||||||
|
expect.objectContaining({ kind: "covers" }),
|
||||||
|
);
|
||||||
|
expect(getBlockedBindReasonStringOnly("/var:/var")).toEqual(
|
||||||
|
expect.objectContaining({ kind: "covers" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateBindMounts", () => {
|
||||||
|
it("allows legitimate project directory mounts", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateBindMounts([
|
||||||
|
"/home/user/source:/source:rw",
|
||||||
|
"/home/user/projects:/projects:ro",
|
||||||
|
"/var/data/myapp:/data",
|
||||||
|
"/opt/myapp/config:/config:ro",
|
||||||
|
]),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined or empty binds", () => {
|
||||||
|
expect(() => validateBindMounts(undefined)).not.toThrow();
|
||||||
|
expect(() => validateBindMounts([])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks /etc mount", () => {
|
||||||
|
expect(() => validateBindMounts(["/etc/passwd:/mnt/passwd:ro"])).toThrow(
|
||||||
|
/blocked path "\/etc"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks /proc mount", () => {
|
||||||
|
expect(() => validateBindMounts(["/proc:/proc:ro"])).toThrow(/blocked path "\/proc"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Docker socket mounts (/var/run + /run)", () => {
|
||||||
|
expect(() => validateBindMounts(["/var/run/docker.sock:/var/run/docker.sock"])).toThrow(
|
||||||
|
/docker\.sock/,
|
||||||
|
);
|
||||||
|
expect(() => validateBindMounts(["/run/docker.sock:/run/docker.sock"])).toThrow(/docker\.sock/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks parent mounts that would expose the Docker socket", () => {
|
||||||
|
expect(() => validateBindMounts(["/run:/run"])).toThrow(/blocked path/);
|
||||||
|
expect(() => validateBindMounts(["/var/run:/var/run"])).toThrow(/blocked path/);
|
||||||
|
expect(() => validateBindMounts(["/var:/var"])).toThrow(/blocked path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks paths with .. traversal to dangerous directories", () => {
|
||||||
|
expect(() => validateBindMounts(["/home/user/../../etc/shadow:/mnt/shadow"])).toThrow(
|
||||||
|
/blocked path "\/etc"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks paths with double slashes normalizing to dangerous dirs", () => {
|
||||||
|
expect(() => validateBindMounts(["//etc//passwd:/mnt/passwd"])).toThrow(/blocked path "\/etc"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks symlink escapes into blocked directories", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
|
||||||
|
const link = join(dir, "etc-link");
|
||||||
|
symlinkSync("/etc", link);
|
||||||
|
expect(() => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`])).toThrow(/blocked path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-absolute source paths (relative or named volumes)", () => {
|
||||||
|
expect(() => validateBindMounts(["../etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/);
|
||||||
|
expect(() => validateBindMounts(["etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/);
|
||||||
|
expect(() => validateBindMounts(["myvol:/mnt"])).toThrow(/non-absolute/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateNetworkMode", () => {
|
||||||
|
it("allows bridge/none/custom/undefined", () => {
|
||||||
|
expect(() => validateNetworkMode("bridge")).not.toThrow();
|
||||||
|
expect(() => validateNetworkMode("none")).not.toThrow();
|
||||||
|
expect(() => validateNetworkMode("my-custom-network")).not.toThrow();
|
||||||
|
expect(() => validateNetworkMode(undefined)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks host mode (case-insensitive)", () => {
|
||||||
|
expect(() => validateNetworkMode("host")).toThrow(/network mode "host" is blocked/);
|
||||||
|
expect(() => validateNetworkMode("HOST")).toThrow(/network mode "HOST" is blocked/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateSeccompProfile", () => {
|
||||||
|
it("allows custom profile paths/undefined", () => {
|
||||||
|
expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow();
|
||||||
|
expect(() => validateSeccompProfile(undefined)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks unconfined (case-insensitive)", () => {
|
||||||
|
expect(() => validateSeccompProfile("unconfined")).toThrow(
|
||||||
|
/seccomp profile "unconfined" is blocked/,
|
||||||
|
);
|
||||||
|
expect(() => validateSeccompProfile("Unconfined")).toThrow(
|
||||||
|
/seccomp profile "Unconfined" is blocked/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateApparmorProfile", () => {
|
||||||
|
it("allows named profile/undefined", () => {
|
||||||
|
expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow();
|
||||||
|
expect(() => validateApparmorProfile(undefined)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks unconfined (case-insensitive)", () => {
|
||||||
|
expect(() => validateApparmorProfile("unconfined")).toThrow(
|
||||||
|
/apparmor profile "unconfined" is blocked/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateSandboxSecurity", () => {
|
||||||
|
it("passes with safe config", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateSandboxSecurity({
|
||||||
|
binds: ["/home/user/src:/src:rw"],
|
||||||
|
network: "none",
|
||||||
|
seccompProfile: "/tmp/seccomp.json",
|
||||||
|
apparmorProfile: "openclaw-sandbox",
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
208
src/agents/sandbox/validate-sandbox-security.ts
Normal file
208
src/agents/sandbox/validate-sandbox-security.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Sandbox security validation — blocks dangerous Docker configurations.
|
||||||
|
*
|
||||||
|
* Threat model: local-trusted config, but protect against foot-guns and config injection.
|
||||||
|
* Enforced at runtime when creating sandbox containers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, realpathSync } from "node:fs";
|
||||||
|
import { posix } from "node:path";
|
||||||
|
|
||||||
|
// Targeted denylist: host paths that should never be exposed inside sandbox containers.
|
||||||
|
// Exported for reuse in security audit collectors.
|
||||||
|
export const BLOCKED_HOST_PATHS = [
|
||||||
|
"/etc",
|
||||||
|
"/private/etc",
|
||||||
|
"/proc",
|
||||||
|
"/sys",
|
||||||
|
"/dev",
|
||||||
|
"/root",
|
||||||
|
"/boot",
|
||||||
|
"/var/run/docker.sock",
|
||||||
|
"/private/var/run/docker.sock",
|
||||||
|
"/run/docker.sock",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCKED_NETWORK_MODES = new Set(["host"]);
|
||||||
|
const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]);
|
||||||
|
const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]);
|
||||||
|
|
||||||
|
export type BlockedBindReason =
|
||||||
|
| { kind: "targets"; blockedPath: string }
|
||||||
|
| { kind: "covers"; blockedPath: string }
|
||||||
|
| { kind: "non_absolute"; sourcePath: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the host/source path from a Docker bind mount string.
|
||||||
|
* Format: `source:target[:mode]`
|
||||||
|
*/
|
||||||
|
export function parseBindSourcePath(bind: string): string {
|
||||||
|
const trimmed = bind.trim();
|
||||||
|
const firstColon = trimmed.indexOf(":");
|
||||||
|
if (firstColon <= 0) {
|
||||||
|
// No colon or starts with colon — treat as source.
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.slice(0, firstColon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
|
||||||
|
*/
|
||||||
|
export function normalizeHostPath(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
return posix.normalize(trimmed).replace(/\/+$/, "") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String-only blocked-path check (no filesystem I/O).
|
||||||
|
* Blocks:
|
||||||
|
* - binds that target blocked paths (equal or under)
|
||||||
|
* - binds that cover blocked paths (ancestor mounts like /run or /var)
|
||||||
|
* - non-absolute source paths (relative / volume names) because they are hard to validate safely
|
||||||
|
*/
|
||||||
|
export function getBlockedBindReasonStringOnly(bind: string): BlockedBindReason | null {
|
||||||
|
const sourceRaw = parseBindSourcePath(bind);
|
||||||
|
if (!sourceRaw.startsWith("/")) {
|
||||||
|
return { kind: "non_absolute", sourcePath: sourceRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeHostPath(sourceRaw);
|
||||||
|
|
||||||
|
for (const blocked of BLOCKED_HOST_PATHS) {
|
||||||
|
if (normalized === blocked || normalized.startsWith(blocked + "/")) {
|
||||||
|
return { kind: "targets", blockedPath: blocked };
|
||||||
|
}
|
||||||
|
// Ancestor mounts: mounting /run exposes /run/docker.sock.
|
||||||
|
if (normalized === "/") {
|
||||||
|
return { kind: "covers", blockedPath: blocked };
|
||||||
|
}
|
||||||
|
if (blocked.startsWith(normalized + "/")) {
|
||||||
|
return { kind: "covers", blockedPath: blocked };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRealpathAbsolute(path: string): string {
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Use native when available (keeps platform semantics); normalize for prefix checks.
|
||||||
|
return normalizeHostPath(realpathSync.native(path));
|
||||||
|
} catch {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error {
|
||||||
|
if (params.reason.kind === "non_absolute") {
|
||||||
|
return new Error(
|
||||||
|
`Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` +
|
||||||
|
`"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const verb = params.reason.kind === "covers" ? "covers" : "targets";
|
||||||
|
return new Error(
|
||||||
|
`Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". ` +
|
||||||
|
"Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. " +
|
||||||
|
"Use project-specific paths instead (e.g. /home/user/myproject).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate bind mounts — throws if any source path is dangerous.
|
||||||
|
* Includes a symlink/realpath pass when the source path exists.
|
||||||
|
*/
|
||||||
|
export function validateBindMounts(binds: string[] | undefined): void {
|
||||||
|
if (!binds?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawBind of binds) {
|
||||||
|
const bind = rawBind.trim();
|
||||||
|
if (!bind) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast string-only check (covers .., //, ancestor/descendant logic).
|
||||||
|
const blocked = getBlockedBindReasonStringOnly(bind);
|
||||||
|
if (blocked) {
|
||||||
|
throw formatBindBlockedError({ bind, reason: blocked });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symlink escape hardening: resolve existing absolute paths and re-check.
|
||||||
|
const sourceRaw = parseBindSourcePath(bind);
|
||||||
|
const sourceNormalized = normalizeHostPath(sourceRaw);
|
||||||
|
const sourceReal = tryRealpathAbsolute(sourceNormalized);
|
||||||
|
if (sourceReal !== sourceNormalized) {
|
||||||
|
for (const blockedPath of BLOCKED_HOST_PATHS) {
|
||||||
|
if (sourceReal === blockedPath || sourceReal.startsWith(blockedPath + "/")) {
|
||||||
|
throw formatBindBlockedError({
|
||||||
|
bind,
|
||||||
|
reason: { kind: "targets", blockedPath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (sourceReal === "/") {
|
||||||
|
throw formatBindBlockedError({
|
||||||
|
bind,
|
||||||
|
reason: { kind: "covers", blockedPath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (blockedPath.startsWith(sourceReal + "/")) {
|
||||||
|
throw formatBindBlockedError({
|
||||||
|
bind,
|
||||||
|
reason: { kind: "covers", blockedPath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNetworkMode(network: string | undefined): void {
|
||||||
|
if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) {
|
||||||
|
throw new Error(
|
||||||
|
`Sandbox security: network mode "${network}" is blocked. ` +
|
||||||
|
'Network "host" mode bypasses container network isolation. ' +
|
||||||
|
'Use "bridge" or "none" instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSeccompProfile(profile: string | undefined): void {
|
||||||
|
if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) {
|
||||||
|
throw new Error(
|
||||||
|
`Sandbox security: seccomp profile "${profile}" is blocked. ` +
|
||||||
|
"Disabling seccomp removes syscall filtering and weakens sandbox isolation. " +
|
||||||
|
"Use a custom seccomp profile file or omit this setting.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateApparmorProfile(profile: string | undefined): void {
|
||||||
|
if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) {
|
||||||
|
throw new Error(
|
||||||
|
`Sandbox security: apparmor profile "${profile}" is blocked. ` +
|
||||||
|
"Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. " +
|
||||||
|
"Use a named AppArmor profile or omit this setting.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSandboxSecurity(cfg: {
|
||||||
|
binds?: string[];
|
||||||
|
network?: string;
|
||||||
|
seccompProfile?: string;
|
||||||
|
apparmorProfile?: string;
|
||||||
|
}): void {
|
||||||
|
validateBindMounts(cfg.binds);
|
||||||
|
validateNetworkMode(cfg.network);
|
||||||
|
validateSeccompProfile(cfg.seccompProfile);
|
||||||
|
validateApparmorProfile(cfg.apparmorProfile);
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js";
|
|||||||
import { validateConfigObject } from "./config.js";
|
import { validateConfigObject } from "./config.js";
|
||||||
|
|
||||||
describe("sandbox docker config", () => {
|
describe("sandbox docker config", () => {
|
||||||
it("accepts binds array in sandbox.docker config", () => {
|
it("accepts safe binds array in sandbox.docker config", () => {
|
||||||
const res = validateConfigObject({
|
const res = validateConfigObject({
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
docker: {
|
docker: {
|
||||||
binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"],
|
binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -29,8 +29,8 @@ describe("sandbox docker config", () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([
|
expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([
|
||||||
"/var/run/docker.sock:/var/run/docker.sock",
|
|
||||||
"/home/user/source:/source:rw",
|
"/home/user/source:/source:rw",
|
||||||
|
"/var/data/myapp:/data:ro",
|
||||||
]);
|
]);
|
||||||
expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([
|
expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([
|
||||||
"/home/user/projects:/projects:ro",
|
"/home/user/projects:/projects:ro",
|
||||||
@@ -38,6 +38,51 @@ describe("sandbox docker config", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects network host mode via Zod schema validation", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
sandbox: {
|
||||||
|
docker: {
|
||||||
|
network: "host",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects seccomp unconfined via Zod schema validation", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
sandbox: {
|
||||||
|
docker: {
|
||||||
|
seccompProfile: "unconfined",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects apparmor unconfined via Zod schema validation", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
sandbox: {
|
||||||
|
docker: {
|
||||||
|
apparmorProfile: "unconfined",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects non-string values in binds array", () => {
|
it("rejects non-string values in binds array", () => {
|
||||||
const res = validateConfigObject({
|
const res = validateConfigObject({
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -125,6 +125,34 @@ export const SandboxDockerSchema = z
|
|||||||
binds: z.array(z.string()).optional(),
|
binds: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.network?.trim().toLowerCase() === "host") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["network"],
|
||||||
|
message:
|
||||||
|
'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.seccompProfile?.trim().toLowerCase() === "unconfined") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["seccompProfile"],
|
||||||
|
message:
|
||||||
|
'Sandbox security: seccomp profile "unconfined" is blocked. ' +
|
||||||
|
"Use a custom seccomp profile file or omit this setting.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.apparmorProfile?.trim().toLowerCase() === "unconfined") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["apparmorProfile"],
|
||||||
|
message:
|
||||||
|
'Sandbox security: apparmor profile "unconfined" is blocked. ' +
|
||||||
|
"Use a named AppArmor profile or omit this setting.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
export const SandboxBrowserSchema = z
|
export const SandboxBrowserSchema = z
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveSandboxConfigForAgent,
|
resolveSandboxConfigForAgent,
|
||||||
resolveSandboxToolPolicyForAgent,
|
resolveSandboxToolPolicyForAgent,
|
||||||
} from "../agents/sandbox.js";
|
} from "../agents/sandbox.js";
|
||||||
|
import { getBlockedBindReasonStringOnly } from "../agents/sandbox/validate-sandbox-security.js";
|
||||||
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||||
import { resolveBrowserConfig } from "../browser/config.js";
|
import { resolveBrowserConfig } from "../browser/config.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
@@ -584,6 +585,104 @@ export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityA
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||||
|
const findings: SecurityAuditFinding[] = [];
|
||||||
|
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||||
|
|
||||||
|
const configs: Array<{ source: string; docker: Record<string, unknown> }> = [];
|
||||||
|
const defaultDocker = cfg.agents?.defaults?.sandbox?.docker;
|
||||||
|
if (defaultDocker && typeof defaultDocker === "object") {
|
||||||
|
configs.push({
|
||||||
|
source: "agents.defaults.sandbox.docker",
|
||||||
|
docker: defaultDocker as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const entry of agents) {
|
||||||
|
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const agentDocker = entry.sandbox?.docker;
|
||||||
|
if (agentDocker && typeof agentDocker === "object") {
|
||||||
|
configs.push({
|
||||||
|
source: `agents.list.${entry.id}.sandbox.docker`,
|
||||||
|
docker: agentDocker as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { source, docker } of configs) {
|
||||||
|
const binds = Array.isArray(docker.binds) ? docker.binds : [];
|
||||||
|
for (const bind of binds) {
|
||||||
|
if (typeof bind !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const blocked = getBlockedBindReasonStringOnly(bind);
|
||||||
|
if (!blocked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (blocked.kind === "non_absolute") {
|
||||||
|
findings.push({
|
||||||
|
checkId: "sandbox.bind_mount_non_absolute",
|
||||||
|
severity: "warn",
|
||||||
|
title: "Sandbox bind mount uses a non-absolute source path",
|
||||||
|
detail:
|
||||||
|
`${source}.binds contains "${bind}" which uses source path "${blocked.sourcePath}". ` +
|
||||||
|
"Non-absolute bind sources are hard to validate safely and may resolve unexpectedly.",
|
||||||
|
remediation: `Rewrite "${bind}" to use an absolute host path (for example: /home/user/project:/project:ro).`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const verb = blocked.kind === "covers" ? "covers" : "targets";
|
||||||
|
findings.push({
|
||||||
|
checkId: "sandbox.dangerous_bind_mount",
|
||||||
|
severity: "critical",
|
||||||
|
title: "Dangerous bind mount in sandbox config",
|
||||||
|
detail:
|
||||||
|
`${source}.binds contains "${bind}" which ${verb} blocked path "${blocked.blockedPath}". ` +
|
||||||
|
"This can expose host system directories or the Docker socket to sandbox containers.",
|
||||||
|
remediation: `Remove "${bind}" from ${source}.binds. Use project-specific paths instead.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const network = typeof docker.network === "string" ? docker.network : undefined;
|
||||||
|
if (network && network.trim().toLowerCase() === "host") {
|
||||||
|
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".`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seccompProfile =
|
||||||
|
typeof docker.seccompProfile === "string" ? docker.seccompProfile : undefined;
|
||||||
|
if (seccompProfile && seccompProfile.trim().toLowerCase() === "unconfined") {
|
||||||
|
findings.push({
|
||||||
|
checkId: "sandbox.dangerous_seccomp_profile",
|
||||||
|
severity: "critical",
|
||||||
|
title: "Seccomp unconfined in sandbox config",
|
||||||
|
detail: `${source}.seccompProfile is "unconfined" which disables syscall filtering.`,
|
||||||
|
remediation: `Remove ${source}.seccompProfile or use a custom seccomp profile file.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apparmorProfile =
|
||||||
|
typeof docker.apparmorProfile === "string" ? docker.apparmorProfile : undefined;
|
||||||
|
if (apparmorProfile && apparmorProfile.trim().toLowerCase() === "unconfined") {
|
||||||
|
findings.push({
|
||||||
|
checkId: "sandbox.dangerous_apparmor_profile",
|
||||||
|
severity: "critical",
|
||||||
|
title: "AppArmor unconfined in sandbox config",
|
||||||
|
detail: `${source}.apparmorProfile is "unconfined" which disables AppArmor enforcement.`,
|
||||||
|
remediation: `Remove ${source}.apparmorProfile or use a named AppArmor profile.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
const denyListRaw = cfg.gateway?.nodes?.denyCommands;
|
const denyListRaw = cfg.gateway?.nodes?.denyCommands;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export {
|
|||||||
collectMinimalProfileOverrideFindings,
|
collectMinimalProfileOverrideFindings,
|
||||||
collectModelHygieneFindings,
|
collectModelHygieneFindings,
|
||||||
collectNodeDenyCommandPatternFindings,
|
collectNodeDenyCommandPatternFindings,
|
||||||
|
collectSandboxDangerousConfigFindings,
|
||||||
collectSandboxDockerNoopFindings,
|
collectSandboxDockerNoopFindings,
|
||||||
collectSecretsInConfigFindings,
|
collectSecretsInConfigFindings,
|
||||||
collectSmallModelRiskFindings,
|
collectSmallModelRiskFindings,
|
||||||
|
|||||||
@@ -486,6 +486,48 @@ describe("security audit", () => {
|
|||||||
expect(res.findings.some((f) => f.checkId === "sandbox.docker_config_mode_off")).toBe(false);
|
expect(res.findings.some((f) => f.checkId === "sandbox.docker_config_mode_off")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
sandbox: {
|
||||||
|
mode: "all",
|
||||||
|
docker: {
|
||||||
|
binds: ["/etc/passwd:/mnt/passwd:ro", "/run:/run"],
|
||||||
|
network: "host",
|
||||||
|
seccompProfile: "unconfined",
|
||||||
|
apparmorProfile: "unconfined",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ checkId: "sandbox.dangerous_bind_mount", severity: "critical" }),
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "sandbox.dangerous_network_mode",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "sandbox.dangerous_seccomp_profile",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "sandbox.dangerous_apparmor_profile",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("flags ineffective gateway.nodes.denyCommands entries", async () => {
|
it("flags ineffective gateway.nodes.denyCommands entries", async () => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
collectModelHygieneFindings,
|
collectModelHygieneFindings,
|
||||||
collectNodeDenyCommandPatternFindings,
|
collectNodeDenyCommandPatternFindings,
|
||||||
collectSmallModelRiskFindings,
|
collectSmallModelRiskFindings,
|
||||||
|
collectSandboxDangerousConfigFindings,
|
||||||
collectSandboxDockerNoopFindings,
|
collectSandboxDockerNoopFindings,
|
||||||
collectPluginsTrustFindings,
|
collectPluginsTrustFindings,
|
||||||
collectSecretsInConfigFindings,
|
collectSecretsInConfigFindings,
|
||||||
@@ -621,6 +622,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
|||||||
findings.push(...collectHooksHardeningFindings(cfg, env));
|
findings.push(...collectHooksHardeningFindings(cfg, env));
|
||||||
findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg));
|
findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg));
|
||||||
findings.push(...collectSandboxDockerNoopFindings(cfg));
|
findings.push(...collectSandboxDockerNoopFindings(cfg));
|
||||||
|
findings.push(...collectSandboxDangerousConfigFindings(cfg));
|
||||||
findings.push(...collectNodeDenyCommandPatternFindings(cfg));
|
findings.push(...collectNodeDenyCommandPatternFindings(cfg));
|
||||||
findings.push(...collectMinimalProfileOverrideFindings(cfg));
|
findings.push(...collectMinimalProfileOverrideFindings(cfg));
|
||||||
findings.push(...collectSecretsInConfigFindings(cfg));
|
findings.push(...collectSecretsInConfigFindings(cfg));
|
||||||
|
|||||||
Reference in New Issue
Block a user