295 lines
9.3 KiB
TypeScript
295 lines
9.3 KiB
TypeScript
import { mkdirSync, mkdtempSync, symlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
getBlockedBindReason,
|
|
validateBindMounts,
|
|
validateNetworkMode,
|
|
validateSeccompProfile,
|
|
validateApparmorProfile,
|
|
validateSandboxSecurity,
|
|
} from "./validate-sandbox-security.js";
|
|
|
|
function expectBindMountsToThrow(binds: string[], expected: RegExp, label: string) {
|
|
expect(() => validateBindMounts(binds), label).toThrow(expected);
|
|
}
|
|
|
|
describe("getBlockedBindReason", () => {
|
|
it("blocks common Docker socket directories", () => {
|
|
expect(getBlockedBindReason("/run:/run")).toEqual(expect.objectContaining({ kind: "targets" }));
|
|
expect(getBlockedBindReason("/var/run:/var/run:ro")).toEqual(
|
|
expect.objectContaining({ kind: "targets" }),
|
|
);
|
|
});
|
|
|
|
it("does not block /var by default", () => {
|
|
expect(getBlockedBindReason("/var:/var")).toBeNull();
|
|
});
|
|
});
|
|
|
|
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 dangerous bind source paths", () => {
|
|
const cases = [
|
|
{
|
|
name: "host root mount",
|
|
binds: ["/:/mnt/host"],
|
|
expected: /blocked path "\/"/,
|
|
},
|
|
{
|
|
name: "etc mount",
|
|
binds: ["/etc/passwd:/mnt/passwd:ro"],
|
|
expected: /blocked path "\/etc"/,
|
|
},
|
|
{
|
|
name: "proc mount",
|
|
binds: ["/proc:/proc:ro"],
|
|
expected: /blocked path "\/proc"/,
|
|
},
|
|
{
|
|
name: "docker socket in /var/run",
|
|
binds: ["/var/run/docker.sock:/var/run/docker.sock"],
|
|
expected: /docker\.sock/,
|
|
},
|
|
{
|
|
name: "docker socket in /run",
|
|
binds: ["/run/docker.sock:/run/docker.sock"],
|
|
expected: /docker\.sock/,
|
|
},
|
|
{
|
|
name: "parent /run mount",
|
|
binds: ["/run:/run"],
|
|
expected: /blocked path/,
|
|
},
|
|
{
|
|
name: "parent /var/run mount",
|
|
binds: ["/var/run:/var/run"],
|
|
expected: /blocked path/,
|
|
},
|
|
{
|
|
name: "traversal into /etc",
|
|
binds: ["/home/user/../../etc/shadow:/mnt/shadow"],
|
|
expected: /blocked path "\/etc"/,
|
|
},
|
|
{
|
|
name: "double-slash normalization into /etc",
|
|
binds: ["//etc//passwd:/mnt/passwd"],
|
|
expected: /blocked path "\/etc"/,
|
|
},
|
|
] as const;
|
|
for (const testCase of cases) {
|
|
expectBindMountsToThrow([...testCase.binds], testCase.expected, testCase.name);
|
|
}
|
|
});
|
|
|
|
it("allows parent mounts that are not blocked", () => {
|
|
expect(() => validateBindMounts(["/var:/var"])).not.toThrow();
|
|
});
|
|
|
|
it("blocks symlink escapes into blocked directories", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
|
|
const link = join(dir, "etc-link");
|
|
symlinkSync("/etc", link);
|
|
const run = () => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`]);
|
|
|
|
if (process.platform === "win32") {
|
|
// Windows source paths (e.g. C:\...) are intentionally rejected as non-POSIX.
|
|
expect(run).toThrow(/non-absolute source path/);
|
|
return;
|
|
}
|
|
|
|
expect(run).toThrow(/blocked path/);
|
|
});
|
|
|
|
it("blocks symlink-parent escapes with non-existent leaf outside allowed roots", () => {
|
|
if (process.platform === "win32") {
|
|
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
|
|
return;
|
|
}
|
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
|
|
const workspace = join(dir, "workspace");
|
|
const outside = join(dir, "outside");
|
|
mkdirSync(workspace, { recursive: true });
|
|
mkdirSync(outside, { recursive: true });
|
|
const link = join(workspace, "alias-out");
|
|
symlinkSync(outside, link);
|
|
const missingLeaf = join(link, "not-yet-created");
|
|
expect(() =>
|
|
validateBindMounts([`${missingLeaf}:/mnt/data:ro`], {
|
|
allowedSourceRoots: [workspace],
|
|
}),
|
|
).toThrow(/outside allowed roots/);
|
|
});
|
|
|
|
it("blocks symlink-parent escapes into blocked paths when leaf does not exist", () => {
|
|
if (process.platform === "win32") {
|
|
// Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX.
|
|
return;
|
|
}
|
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
|
|
const workspace = join(dir, "workspace");
|
|
mkdirSync(workspace, { recursive: true });
|
|
const link = join(workspace, "run-link");
|
|
symlinkSync("/var/run", link);
|
|
const missingLeaf = join(link, "openclaw-not-created");
|
|
expect(() =>
|
|
validateBindMounts([`${missingLeaf}:/mnt/run:ro`], {
|
|
allowedSourceRoots: [workspace],
|
|
}),
|
|
).toThrow(/blocked path/);
|
|
});
|
|
|
|
it("rejects non-absolute source paths (relative or named volumes)", () => {
|
|
const cases = ["../etc/passwd:/mnt/passwd", "etc/passwd:/mnt/passwd", "myvol:/mnt"] as const;
|
|
for (const source of cases) {
|
|
expectBindMountsToThrow([source], /non-absolute/, source);
|
|
}
|
|
});
|
|
|
|
it("blocks bind sources outside allowed roots when allowlist is configured", () => {
|
|
expect(() =>
|
|
validateBindMounts(["/opt/external:/data:ro"], {
|
|
allowedSourceRoots: ["/home/user/project"],
|
|
}),
|
|
).toThrow(/outside allowed roots/);
|
|
});
|
|
|
|
it("allows bind sources in allowed roots when allowlist is configured", () => {
|
|
expect(() =>
|
|
validateBindMounts(["/home/user/project/cache:/data:ro"], {
|
|
allowedSourceRoots: ["/home/user/project"],
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("allows bind sources outside allowed roots with explicit dangerous override", () => {
|
|
expect(() =>
|
|
validateBindMounts(["/opt/external:/data:ro"], {
|
|
allowedSourceRoots: ["/home/user/project"],
|
|
allowSourcesOutsideAllowedRoots: true,
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("blocks reserved container target paths by default", () => {
|
|
expect(() =>
|
|
validateBindMounts([
|
|
"/home/user/project:/workspace:rw",
|
|
"/home/user/project:/agent/cache:rw",
|
|
]),
|
|
).toThrow(/reserved container path/);
|
|
});
|
|
|
|
it("allows reserved container target paths with explicit dangerous override", () => {
|
|
expect(() =>
|
|
validateBindMounts(["/home/user/project:/workspace:rw"], {
|
|
allowReservedContainerTargets: true,
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
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)", () => {
|
|
const cases = [
|
|
{ mode: "host", expected: /network mode "host" is blocked/ },
|
|
{ mode: "HOST", expected: /network mode "HOST" is blocked/ },
|
|
] as const;
|
|
for (const testCase of cases) {
|
|
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", () => {
|
|
it("allows custom profile paths/undefined", () => {
|
|
expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow();
|
|
expect(() => validateSeccompProfile(undefined)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("validateApparmorProfile", () => {
|
|
it("allows named profile/undefined", () => {
|
|
expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow();
|
|
expect(() => validateApparmorProfile(undefined)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("profile hardening", () => {
|
|
it.each([
|
|
{
|
|
name: "seccomp",
|
|
run: (value: string) => validateSeccompProfile(value),
|
|
expected: /seccomp profile ".+" is blocked/,
|
|
},
|
|
{
|
|
name: "apparmor",
|
|
run: (value: string) => validateApparmorProfile(value),
|
|
expected: /apparmor profile ".+" is blocked/,
|
|
},
|
|
])("blocks unconfined profiles (case-insensitive): $name", ({ run, expected }) => {
|
|
expect(() => run("unconfined")).toThrow(expected);
|
|
expect(() => run("Unconfined")).toThrow(expected);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|