Skip to content

Commit 74ecdec

Browse files
committed
fix(security): harden fs-safe copy writes
1 parent 6bfae27 commit 74ecdec

2 files changed

Lines changed: 99 additions & 4 deletions

File tree

src/infra/fs-safe.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,66 @@ describe("fs-safe", () => {
300300
},
301301
);
302302

303+
it("does not truncate existing target when atomic copy rename fails", async () => {
304+
const root = await tempDirs.make("openclaw-fs-safe-root-");
305+
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
306+
const sourcePath = path.join(sourceDir, "in.txt");
307+
const targetPath = path.join(root, "nested", "copied.txt");
308+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
309+
await fs.writeFile(sourcePath, "copy-new");
310+
await fs.writeFile(targetPath, "copy-existing");
311+
const renameSpy = vi
312+
.spyOn(fs, "rename")
313+
.mockRejectedValue(Object.assign(new Error("rename blocked"), { code: "EACCES" }));
314+
try {
315+
await expect(
316+
copyFileWithinRoot({
317+
sourcePath,
318+
rootDir: root,
319+
relativePath: "nested/copied.txt",
320+
}),
321+
).rejects.toMatchObject({ code: "EACCES" });
322+
} finally {
323+
renameSpy.mockRestore();
324+
}
325+
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("copy-existing");
326+
});
327+
328+
it.runIf(process.platform !== "win32")(
329+
"rejects when a hardlink appears after atomic copy rename",
330+
async () => {
331+
const root = await tempDirs.make("openclaw-fs-safe-root-");
332+
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
333+
const sourcePath = path.join(sourceDir, "copy-source.txt");
334+
const targetPath = path.join(root, "nested", "copied.txt");
335+
const aliasPath = path.join(root, "nested", "alias.txt");
336+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
337+
await fs.writeFile(sourcePath, "copy-new");
338+
await fs.writeFile(targetPath, "copy-existing");
339+
const realRename = fs.rename.bind(fs);
340+
let linked = false;
341+
const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (...args) => {
342+
await realRename(...args);
343+
if (!linked) {
344+
linked = true;
345+
await fs.link(String(args[1]), aliasPath);
346+
}
347+
});
348+
try {
349+
await expect(
350+
copyFileWithinRoot({
351+
sourcePath,
352+
rootDir: root,
353+
relativePath: "nested/copied.txt",
354+
}),
355+
).rejects.toMatchObject({ code: "invalid-path" });
356+
} finally {
357+
renameSpy.mockRestore();
358+
}
359+
await expect(fs.readFile(aliasPath, "utf8")).resolves.toBe("copy-new");
360+
},
361+
);
362+
303363
it("copies a file within root safely", async () => {
304364
const root = await tempDirs.make("openclaw-fs-safe-root-");
305365
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");

src/infra/fs-safe.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -554,32 +554,67 @@ export async function copyFileWithinRoot(params: {
554554

555555
let target: SafeWritableOpenResult | null = null;
556556
let sourceClosedByStream = false;
557-
let targetClosedByStream = false;
557+
let targetClosedByUs = false;
558+
let tempHandle: FileHandle | null = null;
559+
let tempPath: string | null = null;
560+
let tempClosedByStream = false;
558561
try {
559562
target = await openWritableFileWithinRoot({
560563
rootDir: params.rootDir,
561564
relativePath: params.relativePath,
562565
mkdir: params.mkdir,
566+
truncateExisting: false,
563567
});
568+
const destinationPath = target.openedRealPath;
569+
const targetMode = target.openedStat.mode & 0o777;
570+
await target.handle.close().catch(() => {});
571+
targetClosedByUs = true;
572+
573+
tempPath = buildAtomicWriteTempPath(destinationPath);
574+
tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, targetMode || 0o600);
564575
const sourceStream = source.handle.createReadStream();
565-
const targetStream = target.handle.createWriteStream();
576+
const targetStream = tempHandle.createWriteStream();
566577
sourceStream.once("close", () => {
567578
sourceClosedByStream = true;
568579
});
569580
targetStream.once("close", () => {
570-
targetClosedByStream = true;
581+
tempClosedByStream = true;
571582
});
572583
await pipeline(sourceStream, targetStream);
584+
const writtenStat = await fs.stat(tempPath);
585+
if (!tempClosedByStream) {
586+
await tempHandle.close().catch(() => {});
587+
tempClosedByStream = true;
588+
}
589+
tempHandle = null;
590+
await fs.rename(tempPath, destinationPath);
591+
tempPath = null;
592+
try {
593+
await verifyAtomicWriteResult({
594+
rootDir: params.rootDir,
595+
targetPath: destinationPath,
596+
expectedStat: writtenStat,
597+
});
598+
} catch (err) {
599+
emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
600+
throw err;
601+
}
573602
} catch (err) {
574603
if (target?.createdForWrite) {
575604
await fs.rm(target.openedRealPath, { force: true }).catch(() => {});
576605
}
577606
throw err;
578607
} finally {
608+
if (tempPath) {
609+
await fs.rm(tempPath, { force: true }).catch(() => {});
610+
}
579611
if (!sourceClosedByStream) {
580612
await source.handle.close().catch(() => {});
581613
}
582-
if (target && !targetClosedByStream) {
614+
if (tempHandle && !tempClosedByStream) {
615+
await tempHandle.close().catch(() => {});
616+
}
617+
if (target && !targetClosedByUs) {
583618
await target.handle.close().catch(() => {});
584619
}
585620
}

0 commit comments

Comments
 (0)