mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 14:17:14 +00:00
fix(sandbox): harden bind validation for symlink missing-leaf paths
This commit is contained in:
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||||
- Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase.
|
- Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase.
|
||||||
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
|
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
|
||||||
|
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks.
|
||||||
|
|
||||||
## 2026.2.23
|
## 2026.2.23
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtempSync, symlinkSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, symlinkSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
@@ -117,6 +117,44 @@ describe("validateBindMounts", () => {
|
|||||||
expect(run).toThrow(/blocked path/);
|
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)", () => {
|
it("rejects non-absolute source paths (relative or named volumes)", () => {
|
||||||
const cases = ["../etc/passwd:/mnt/passwd", "etc/passwd:/mnt/passwd", "myvol:/mnt"] as const;
|
const cases = ["../etc/passwd:/mnt/passwd", "etc/passwd:/mnt/passwd", "myvol:/mnt"] as const;
|
||||||
for (const source of cases) {
|
for (const source of cases) {
|
||||||
|
|||||||
@@ -119,18 +119,38 @@ export function getBlockedReasonForSourcePath(sourceNormalized: string): Blocked
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryRealpathAbsolute(path: string): string {
|
function resolvePathViaExistingAncestor(sourcePath: string): string {
|
||||||
if (!path.startsWith("/")) {
|
if (!sourcePath.startsWith("/")) {
|
||||||
return path;
|
return sourcePath;
|
||||||
}
|
}
|
||||||
if (!existsSync(path)) {
|
|
||||||
return path;
|
const normalized = normalizeHostPath(sourcePath);
|
||||||
|
let current = normalized;
|
||||||
|
const missingSegments: string[] = [];
|
||||||
|
|
||||||
|
// Resolve through the deepest existing ancestor so symlink parents are honored
|
||||||
|
// even when the final source leaf does not exist yet.
|
||||||
|
while (current !== "/" && !existsSync(current)) {
|
||||||
|
missingSegments.unshift(posix.basename(current));
|
||||||
|
const parent = posix.dirname(current);
|
||||||
|
if (parent === current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!existsSync(current)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use native when available (keeps platform semantics); normalize for prefix checks.
|
const resolvedAncestor = normalizeHostPath(realpathSync.native(current));
|
||||||
return normalizeHostPath(realpathSync.native(path));
|
if (missingSegments.length === 0) {
|
||||||
|
return resolvedAncestor;
|
||||||
|
}
|
||||||
|
return normalizeHostPath(posix.join(resolvedAncestor, ...missingSegments));
|
||||||
} catch {
|
} catch {
|
||||||
return path;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +165,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] {
|
|||||||
const expanded = new Set<string>();
|
const expanded = new Set<string>();
|
||||||
for (const root of normalized) {
|
for (const root of normalized) {
|
||||||
expanded.add(root);
|
expanded.add(root);
|
||||||
const real = tryRealpathAbsolute(root);
|
const real = resolvePathViaExistingAncestor(root);
|
||||||
if (real !== root) {
|
if (real !== root) {
|
||||||
expanded.add(real);
|
expanded.add(real);
|
||||||
}
|
}
|
||||||
@@ -227,7 +247,8 @@ function formatBindBlockedError(params: { bind: string; reason: BlockedBindReaso
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate bind mounts — throws if any source path is dangerous.
|
* Validate bind mounts — throws if any source path is dangerous.
|
||||||
* Includes a symlink/realpath pass when the source path exists.
|
* Includes a symlink/realpath pass via existing ancestors so non-existent leaf
|
||||||
|
* paths cannot bypass source-root and blocked-path checks.
|
||||||
*/
|
*/
|
||||||
export function validateBindMounts(
|
export function validateBindMounts(
|
||||||
binds: string[] | undefined,
|
binds: string[] | undefined,
|
||||||
@@ -268,18 +289,16 @@ export function validateBindMounts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Symlink escape hardening: resolve existing absolute paths and re-check.
|
// Symlink escape hardening: resolve through existing ancestors and re-check.
|
||||||
const sourceReal = tryRealpathAbsolute(sourceNormalized);
|
const sourceCanonical = resolvePathViaExistingAncestor(sourceNormalized);
|
||||||
if (sourceReal !== sourceNormalized) {
|
const reason = getBlockedReasonForSourcePath(sourceCanonical);
|
||||||
const reason = getBlockedReasonForSourcePath(sourceReal);
|
if (reason) {
|
||||||
if (reason) {
|
throw formatBindBlockedError({ bind, reason });
|
||||||
throw formatBindBlockedError({ bind, reason });
|
}
|
||||||
}
|
if (!options?.allowSourcesOutsideAllowedRoots) {
|
||||||
if (!options?.allowSourcesOutsideAllowedRoots) {
|
const allowedReason = getOutsideAllowedRootsReason(sourceCanonical, allowedRoots);
|
||||||
const allowedReason = getOutsideAllowedRootsReason(sourceReal, allowedRoots);
|
if (allowedReason) {
|
||||||
if (allowedReason) {
|
throw formatBindBlockedError({ bind, reason: allowedReason });
|
||||||
throw formatBindBlockedError({ bind, reason: allowedReason });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user