fix(security): unify root-bound write hardening

This commit is contained in:
Peter Steinberger
2026-03-02 17:11:04 +00:00
parent be3a62c5e0
commit 104d32bb64
13 changed files with 427 additions and 41 deletions

View File

@@ -11,6 +11,7 @@ import {
readPathWithinRoot,
readLocalFileSafely,
writeFileWithinRoot,
writeFileFromPathWithinRoot,
} from "./fs-safe.js";
const tempDirs = createTrackedTempDirs();
@@ -213,6 +214,20 @@ describe("fs-safe", () => {
});
});
it("writes a file within root from another local source path safely", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const outside = await tempDirs.make("openclaw-fs-safe-src-");
const sourcePath = path.join(outside, "source.bin");
await fs.writeFile(sourcePath, "hello-from-source");
await writeFileFromPathWithinRoot({
rootDir: root,
relativePath: "nested/from-source.txt",
sourcePath,
});
await expect(fs.readFile(path.join(root, "nested", "from-source.txt"), "utf8")).resolves.toBe(
"hello-from-source",
);
});
it("rejects write traversal outside root", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
await expect(
@@ -295,6 +310,49 @@ describe("fs-safe", () => {
},
);
it.runIf(process.platform !== "win32")(
"does not clobber out-of-root file when symlink retarget races write-from-path open",
async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const inside = path.join(root, "inside");
const outside = await tempDirs.make("openclaw-fs-safe-outside-");
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
const sourcePath = path.join(sourceDir, "source.txt");
await fs.writeFile(sourcePath, "new-content");
await fs.mkdir(inside, { recursive: true });
const outsideTarget = path.join(outside, "target.txt");
await fs.writeFile(outsideTarget, "X".repeat(4096));
const slot = path.join(root, "slot");
await fs.symlink(inside, slot);
const realRealpath = fs.realpath.bind(fs);
let flipped = false;
const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (...args) => {
const [filePath] = args;
if (!flipped && String(filePath).endsWith(path.join("slot", "target.txt"))) {
flipped = true;
await fs.rm(slot, { recursive: true, force: true });
await fs.symlink(outside, slot);
}
return await realRealpath(...args);
});
try {
await expect(
writeFileFromPathWithinRoot({
rootDir: root,
relativePath: path.join("slot", "target.txt"),
sourcePath,
mkdir: false,
}),
).rejects.toMatchObject({ code: "outside-workspace" });
} finally {
realpathSpy.mockRestore();
}
await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
},
);
it.runIf(process.platform !== "win32")(
"cleans up created out-of-root file when symlink retarget races create path",
async () => {