mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 15:01:11 +00:00
refactor(security): split sandbox media staging and stream safe copies
This commit is contained in:
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||
import {
|
||||
copyFileWithinRoot,
|
||||
createRootScopedReadFile,
|
||||
SafeOpenError,
|
||||
openFileWithinRoot,
|
||||
@@ -176,6 +177,42 @@ describe("fs-safe", () => {
|
||||
await expect(fs.readFile(path.join(root, "nested", "out.txt"), "utf8")).resolves.toBe("hello");
|
||||
});
|
||||
|
||||
it("copies a file within root safely", async () => {
|
||||
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
|
||||
const sourcePath = path.join(sourceDir, "in.txt");
|
||||
await fs.writeFile(sourcePath, "copy-ok");
|
||||
|
||||
await copyFileWithinRoot({
|
||||
sourcePath,
|
||||
rootDir: root,
|
||||
relativePath: "nested/copied.txt",
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(root, "nested", "copied.txt"), "utf8")).resolves.toBe(
|
||||
"copy-ok",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces maxBytes when copying into root", async () => {
|
||||
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
|
||||
const sourcePath = path.join(sourceDir, "big.bin");
|
||||
await fs.writeFile(sourcePath, Buffer.alloc(8));
|
||||
|
||||
await expect(
|
||||
copyFileWithinRoot({
|
||||
sourcePath,
|
||||
rootDir: root,
|
||||
relativePath: "nested/big.bin",
|
||||
maxBytes: 4,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "too-large" });
|
||||
await expect(fs.stat(path.join(root, "nested", "big.bin"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects write traversal outside root", async () => {
|
||||
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||
await expect(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { sameFileIdentity } from "./file-identity.js";
|
||||
import { expandHomePrefix } from "./home-dir.js";
|
||||
import { assertNoPathAliasEscape } from "./path-alias-guards.js";
|
||||
@@ -282,13 +283,15 @@ async function readOpenedFileSafely(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeFileWithinRoot(params: {
|
||||
async function openWritableFileWithinRoot(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
data: string | Buffer;
|
||||
encoding?: BufferEncoding;
|
||||
mkdir?: boolean;
|
||||
}): Promise<void> {
|
||||
}): Promise<{
|
||||
handle: FileHandle;
|
||||
createdForWrite: boolean;
|
||||
openedRealPath: string;
|
||||
}> {
|
||||
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
||||
try {
|
||||
await assertNoPathAliasEscape({
|
||||
@@ -372,17 +375,92 @@ export async function writeFileWithinRoot(params: {
|
||||
if (!createdForWrite) {
|
||||
await handle.truncate(0);
|
||||
}
|
||||
if (typeof params.data === "string") {
|
||||
await handle.writeFile(params.data, params.encoding ?? "utf8");
|
||||
} else {
|
||||
await handle.writeFile(params.data);
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
createdForWrite,
|
||||
openedRealPath: realPath,
|
||||
};
|
||||
} catch (err) {
|
||||
if (createdForWrite && err instanceof SafeOpenError && openedRealPath) {
|
||||
await fs.rm(openedRealPath, { force: true }).catch(() => {});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeFileWithinRoot(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
data: string | Buffer;
|
||||
encoding?: BufferEncoding;
|
||||
mkdir?: boolean;
|
||||
}): Promise<void> {
|
||||
const target = await openWritableFileWithinRoot({
|
||||
rootDir: params.rootDir,
|
||||
relativePath: params.relativePath,
|
||||
mkdir: params.mkdir,
|
||||
});
|
||||
try {
|
||||
if (typeof params.data === "string") {
|
||||
await target.handle.writeFile(params.data, params.encoding ?? "utf8");
|
||||
} else {
|
||||
await target.handle.writeFile(params.data);
|
||||
}
|
||||
} finally {
|
||||
await target.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyFileWithinRoot(params: {
|
||||
sourcePath: string;
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
maxBytes?: number;
|
||||
mkdir?: boolean;
|
||||
}): Promise<void> {
|
||||
const source = await openVerifiedLocalFile(params.sourcePath);
|
||||
if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
|
||||
await source.handle.close().catch(() => {});
|
||||
throw new SafeOpenError(
|
||||
"too-large",
|
||||
`file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`,
|
||||
);
|
||||
}
|
||||
|
||||
let target: {
|
||||
handle: FileHandle;
|
||||
createdForWrite: boolean;
|
||||
openedRealPath: string;
|
||||
} | null = null;
|
||||
let sourceClosedByStream = false;
|
||||
let targetClosedByStream = false;
|
||||
try {
|
||||
target = await openWritableFileWithinRoot({
|
||||
rootDir: params.rootDir,
|
||||
relativePath: params.relativePath,
|
||||
mkdir: params.mkdir,
|
||||
});
|
||||
const sourceStream = source.handle.createReadStream();
|
||||
const targetStream = target.handle.createWriteStream();
|
||||
sourceStream.once("close", () => {
|
||||
sourceClosedByStream = true;
|
||||
});
|
||||
targetStream.once("close", () => {
|
||||
targetClosedByStream = true;
|
||||
});
|
||||
await pipeline(sourceStream, targetStream);
|
||||
} catch (err) {
|
||||
if (target?.createdForWrite) {
|
||||
await fs.rm(target.openedRealPath, { force: true }).catch(() => {});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (!sourceClosedByStream) {
|
||||
await source.handle.close().catch(() => {});
|
||||
}
|
||||
if (target && !targetClosedByStream) {
|
||||
await target.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user