fix(sandbox): block @-prefixed workspace path bypass

This commit is contained in:
Peter Steinberger
2026-02-24 17:22:46 +00:00
parent f154926cc0
commit 9ef0fc2ff8
6 changed files with 58 additions and 3 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting.
- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.

View File

@@ -571,7 +571,7 @@ function mapContainerPathToWorkspaceRoot(params: {
return params.filePath;
}
let candidate = params.filePath;
let candidate = params.filePath.startsWith("@") ? params.filePath.slice(1) : params.filePath;
if (/^file:\/\//i.test(candidate)) {
try {
candidate = fileURLToPath(candidate);

View File

@@ -61,6 +61,36 @@ describe("wrapToolWorkspaceRootGuardWithOptions", () => {
});
});
it("maps @-prefixed container workspace paths to host workspace root", async () => {
const { tool } = createToolHarness();
const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, {
containerWorkdir: "/workspace",
});
await wrapped.execute("tc-at-container", { path: "@/workspace/docs/readme.md" });
expect(mocks.assertSandboxPath).toHaveBeenCalledWith({
filePath: path.resolve(root, "docs", "readme.md"),
cwd: root,
root,
});
});
it("normalizes @-prefixed absolute paths before guard checks", async () => {
const { tool } = createToolHarness();
const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, {
containerWorkdir: "/workspace",
});
await wrapped.execute("tc-at-absolute", { path: "@/etc/passwd" });
expect(mocks.assertSandboxPath).toHaveBeenCalledWith({
filePath: "/etc/passwd",
cwd: root,
root,
});
});
it("does not remap absolute paths outside the configured container workdir", async () => {
const { tool } = createToolHarness();
const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
import { expectReadWriteEditTools, getTextContent } from "./test-helpers/pi-tools-fs-helpers.js";
@@ -137,6 +138,19 @@ describe("workspace path resolution", () => {
});
});
});
it("rejects @-prefixed absolute paths outside workspace when workspaceOnly is enabled", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
const { readTool } = expectReadWriteEditTools(tools);
const outsideAbsolute = path.resolve(path.parse(workspaceDir).root, "outside-openclaw.txt");
await expect(
readTool.execute("ws-read-at-prefix", { path: `@${outsideAbsolute}` }),
).rejects.toThrow(/Path escapes sandbox root/i);
});
});
});
describe("sandboxed workspace paths", () => {

View File

@@ -13,8 +13,12 @@ function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(filePath);
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
if (normalized === "~") {
return os.homedir();
}

View File

@@ -227,7 +227,13 @@ function isPathInsidePosix(root: string, target: string): boolean {
function isPathInsideHost(root: string, target: string): boolean {
const canonicalRoot = resolveSandboxHostPathViaExistingAncestor(path.resolve(root));
const canonicalTarget = resolveSandboxHostPathViaExistingAncestor(path.resolve(target));
const resolvedTarget = path.resolve(target);
// Preserve the final path segment so pre-existing symlink leaves are validated
// by the dedicated symlink guard later in the bridge flow.
const canonicalTargetParent = resolveSandboxHostPathViaExistingAncestor(
path.dirname(resolvedTarget),
);
const canonicalTarget = path.resolve(canonicalTargetParent, path.basename(resolvedTarget));
const rel = path.relative(canonicalRoot, canonicalTarget);
if (!rel) {
return true;