Skip to content

Commit 5441c03

Browse files
committed
Harden sandbox bind source validation
1 parent 52154ed commit 5441c03

3 files changed

Lines changed: 54 additions & 14 deletions

File tree

src/agents/sandbox-create-args.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ describe("buildSandboxCreateArgs", () => {
253253
cfg: createSandboxConfig({}, ["/run:/run"]),
254254
expected: /blocked path/,
255255
},
256+
{
257+
name: "bind source covering Docker socket directory",
258+
containerName: "openclaw-sbx-covers-docker-socket-dir",
259+
cfg: createSandboxConfig({}, ["/var:/var"]),
260+
expected: /covers blocked path/,
261+
},
256262
{
257263
name: "network host mode",
258264
containerName: "openclaw-sbx-host",

src/agents/sandbox/validate-sandbox-security.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,22 @@ describe("getBlockedBindReason", () => {
3636
expectBlockedTargetReason("/var/run:/var/run:ro");
3737
});
3838

39-
it("does not block /var by default", () => {
40-
expect(getBlockedBindReason("/var:/var")).toBeNull();
39+
it("blocks parent sources that cover blocked descendants", () => {
40+
const reason = getBlockedBindReason("/var:/var");
41+
expect(reason).toMatchObject({
42+
kind: "covers",
43+
blockedPath: "/var/run",
44+
});
45+
});
46+
47+
it("blocks home parent sources that cover credential descendants", () => {
48+
withEnv({ HOME: "/home/tester" }, () => {
49+
const reason = getBlockedBindReason("/home/tester:/mnt/home:ro");
50+
expect(reason).toMatchObject({
51+
kind: "covers",
52+
blockedPath: "/home/tester/.aws",
53+
});
54+
});
4155
});
4256

4357
it("blocks sensitive home credential paths", () => {
@@ -165,8 +179,24 @@ describe("validateBindMounts", () => {
165179
}
166180
});
167181

168-
it("allows parent mounts that are not blocked", () => {
169-
expect(validateBindMounts(["/var:/var"])).toBeUndefined();
182+
it("allows parent mounts that do not cover blocked descendants", () => {
183+
expect(validateBindMounts(["/var/data:/data"])).toBeUndefined();
184+
});
185+
186+
it("blocks allowed source roots that cover blocked descendants", () => {
187+
expect(() =>
188+
validateBindMounts(["/var:/var"], {
189+
allowedSourceRoots: ["/var"],
190+
}),
191+
).toThrow(/covers blocked path "\/var\/run"/);
192+
193+
withEnv({ HOME: "/home/tester" }, () => {
194+
expect(() =>
195+
validateBindMounts(["/home/tester:/mnt/home:ro"], {
196+
allowedSourceRoots: ["/home/tester"],
197+
}),
198+
).toThrow(/covers blocked path "\/home\/tester\/\.aws"/);
199+
});
170200
});
171201

172202
it("blocks sensitive home credential binds", () => {

src/agents/sandbox/validate-sandbox-security.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,15 @@ function getBlockedReasonForSourcePath(
143143
if (sourceNormalized === "/") {
144144
return { kind: "covers", blockedPath: "/" };
145145
}
146-
const sourceKey = getSandboxHostPathPolicyKey(sourceNormalized);
147146
for (const blocked of blockedHostPaths) {
148-
const blockedKey = getSandboxHostPathPolicyKey(blocked);
149-
if (sourceKey === blockedKey || sourceKey.startsWith(`${blockedKey}/`)) {
147+
if (isPathInsidePolicyPath(blocked, sourceNormalized)) {
150148
return { kind: "targets", blockedPath: blocked };
151149
}
150+
// Parent mounts can expose blocked descendants such as HOME credentials or
151+
// the Docker socket directory even when the source root itself is allowed.
152+
if (isPathInsidePolicyPath(sourceNormalized, blocked)) {
153+
return { kind: "covers", blockedPath: blocked };
154+
}
152155
}
153156

154157
return null;
@@ -217,13 +220,14 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] {
217220
return [...expanded];
218221
}
219222

220-
function isPathInsidePosix(root: string, target: string): boolean {
221-
if (root === "/") {
222-
return true;
223-
}
223+
function isPathInsidePolicyPath(root: string, target: string): boolean {
224224
const rootKey = getSandboxHostPathPolicyKey(root);
225225
const targetKey = getSandboxHostPathPolicyKey(target);
226-
return targetKey === rootKey || targetKey.startsWith(`${rootKey}/`);
226+
if (rootKey === "/") {
227+
return true;
228+
}
229+
const rootPrefix = rootKey.endsWith("/") ? rootKey : `${rootKey}/`;
230+
return targetKey === rootKey || targetKey.startsWith(rootPrefix);
227231
}
228232

229233
function getOutsideAllowedRootsReason(
@@ -234,7 +238,7 @@ function getOutsideAllowedRootsReason(
234238
return null;
235239
}
236240
for (const root of allowedRoots) {
237-
if (isPathInsidePosix(root, sourceNormalized)) {
241+
if (isPathInsidePolicyPath(root, sourceNormalized)) {
238242
return null;
239243
}
240244
}
@@ -252,7 +256,7 @@ function getReservedTargetReason(bind: string): BlockedBindReason | null {
252256
}
253257
const target = normalizeHostPath(targetRaw);
254258
for (const reserved of RESERVED_CONTAINER_TARGET_PATHS) {
255-
if (isPathInsidePosix(reserved, target)) {
259+
if (isPathInsidePolicyPath(reserved, target)) {
256260
return {
257261
kind: "reserved_target",
258262
targetPath: target,

0 commit comments

Comments
 (0)