fix: harden workspace boundary path resolution

This commit is contained in:
Peter Steinberger
2026-02-26 13:19:55 +01:00
parent ecb2053fdd
commit 46eba86b45
8 changed files with 767 additions and 177 deletions

View File

@@ -195,6 +195,26 @@ describe("resolveSandboxedMediaSource", () => {
});
});
it("rejects sandbox symlink escapes when the outside leaf does not exist yet", async () => {
if (process.platform === "win32") {
return;
}
await withSandboxRoot(async (sandboxDir) => {
const outsideDir = await fs.mkdtemp(
path.join(process.cwd(), "sandbox-media-outside-missing-"),
);
const linkDir = path.join(sandboxDir, "escape-link");
await fs.symlink(outsideDir, linkDir);
try {
const missingOutsidePath = path.join(linkDir, "new-file.txt");
await expectSandboxRejection(missingOutsidePath, sandboxDir, /symlink|sandbox/i);
} finally {
await fs.rm(linkDir, { force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
});
it("rejects hardlinked OpenClaw tmp paths to outside files", async () => {
if (process.platform === "win32") {
return;

View File

@@ -71,7 +71,7 @@ export async function assertSandboxPath(params: {
};
await assertNoPathAliasEscape({
absolutePath: resolved.resolved,
rootPath: path.resolve(params.root),
rootPath: params.root,
boundaryLabel: "sandbox root",
policy,
});

View File

@@ -1,5 +1,5 @@
import { existsSync, realpathSync } from "node:fs";
import { posix } from "node:path";
import { resolvePathViaExistingAncestorSync } from "../../infra/boundary-path.js";
/**
* Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
@@ -17,31 +17,5 @@ export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): s
if (!sourcePath.startsWith("/")) {
return sourcePath;
}
const normalized = normalizeSandboxHostPath(sourcePath);
let current = normalized;
const missingSegments: string[] = [];
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 {
const resolvedAncestor = normalizeSandboxHostPath(realpathSync.native(current));
if (missingSegments.length === 0) {
return resolvedAncestor;
}
return normalizeSandboxHostPath(posix.join(resolvedAncestor, ...missingSegments));
} catch {
return normalized;
}
return normalizeSandboxHostPath(resolvePathViaExistingAncestorSync(sourcePath));
}