mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 18:26:37 +00:00
fix(security): unify root-bound write hardening
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -464,3 +464,115 @@ export async function copyFileWithinRoot(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeFileFromPathWithinRoot(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
sourcePath: string;
|
||||
mkdir?: boolean;
|
||||
}): Promise<void> {
|
||||
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
||||
try {
|
||||
await assertNoPathAliasEscape({
|
||||
absolutePath: resolved,
|
||||
rootPath: rootReal,
|
||||
boundaryLabel: "root",
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SafeOpenError("invalid-path", "path alias escape blocked", { cause: err });
|
||||
}
|
||||
if (params.mkdir !== false) {
|
||||
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
||||
}
|
||||
|
||||
const source = await openVerifiedLocalFile(params.sourcePath, { rejectHardlinks: true });
|
||||
let ioPath = resolved;
|
||||
try {
|
||||
const resolvedRealPath = await fs.realpath(resolved);
|
||||
if (!isPathInside(rootWithSep, resolvedRealPath)) {
|
||||
throw new SafeOpenError("outside-workspace", "file is outside workspace root");
|
||||
}
|
||||
ioPath = resolvedRealPath;
|
||||
} catch (err) {
|
||||
if (err instanceof SafeOpenError) {
|
||||
await source.handle.close().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (!isNotFoundPathError(err)) {
|
||||
await source.handle.close().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let handle: FileHandle;
|
||||
let createdForWrite = false;
|
||||
try {
|
||||
try {
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, 0o600);
|
||||
} catch (err) {
|
||||
if (!isNotFoundPathError(err)) {
|
||||
throw err;
|
||||
}
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, 0o600);
|
||||
createdForWrite = true;
|
||||
}
|
||||
} catch (err) {
|
||||
await source.handle.close().catch(() => {});
|
||||
if (isNotFoundPathError(err)) {
|
||||
throw new SafeOpenError("not-found", "file not found");
|
||||
}
|
||||
if (isSymlinkOpenError(err)) {
|
||||
throw new SafeOpenError("invalid-path", "symlink open blocked", { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let openedRealPath: string | null = null;
|
||||
try {
|
||||
const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(ioPath)]);
|
||||
if (lstat.isSymbolicLink() || !stat.isFile()) {
|
||||
throw new SafeOpenError("invalid-path", "path is not a regular file under root");
|
||||
}
|
||||
if (stat.nlink > 1) {
|
||||
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
||||
}
|
||||
if (!sameFileIdentity(stat, lstat)) {
|
||||
throw new SafeOpenError("path-mismatch", "path changed during write");
|
||||
}
|
||||
|
||||
const realPath = await fs.realpath(ioPath);
|
||||
openedRealPath = realPath;
|
||||
const realStat = await fs.stat(realPath);
|
||||
if (!sameFileIdentity(stat, realStat)) {
|
||||
throw new SafeOpenError("path-mismatch", "path mismatch");
|
||||
}
|
||||
if (realStat.nlink > 1) {
|
||||
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
||||
}
|
||||
if (!isPathInside(rootWithSep, realPath)) {
|
||||
throw new SafeOpenError("outside-workspace", "file is outside workspace root");
|
||||
}
|
||||
|
||||
if (!createdForWrite) {
|
||||
await handle.truncate(0);
|
||||
}
|
||||
const chunk = Buffer.allocUnsafe(64 * 1024);
|
||||
let sourceOffset = 0;
|
||||
while (true) {
|
||||
const { bytesRead } = await source.handle.read(chunk, 0, chunk.length, sourceOffset);
|
||||
if (bytesRead <= 0) {
|
||||
break;
|
||||
}
|
||||
await handle.write(chunk.subarray(0, bytesRead), 0, bytesRead, null);
|
||||
sourceOffset += bytesRead;
|
||||
}
|
||||
} catch (err) {
|
||||
if (createdForWrite && err instanceof SafeOpenError && openedRealPath) {
|
||||
await fs.rm(openedRealPath, { force: true }).catch(() => {});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await source.handle.close().catch(() => {});
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { fileExists } from "./archive.js";
|
||||
import { assertCanonicalPathWithinBase } from "./install-safe-path.js";
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -60,11 +61,23 @@ export async function installPackageDir(params: {
|
||||
afterCopy?: () => void | Promise<void>;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
params.logger?.info?.(`Installing to ${params.targetDir}…`);
|
||||
const installBaseDir = path.dirname(params.targetDir);
|
||||
await fs.mkdir(installBaseDir, { recursive: true });
|
||||
await assertCanonicalPathWithinBase({
|
||||
baseDir: installBaseDir,
|
||||
candidatePath: params.targetDir,
|
||||
boundaryLabel: "install directory",
|
||||
});
|
||||
let backupDir: string | null = null;
|
||||
if (params.mode === "update" && (await fileExists(params.targetDir))) {
|
||||
const backupRoot = path.join(path.dirname(params.targetDir), ".openclaw-install-backups");
|
||||
backupDir = path.join(backupRoot, `${path.basename(params.targetDir)}-${Date.now()}`);
|
||||
await fs.mkdir(backupRoot, { recursive: true });
|
||||
await assertCanonicalPathWithinBase({
|
||||
baseDir: installBaseDir,
|
||||
candidatePath: backupDir,
|
||||
boundaryLabel: "install directory",
|
||||
});
|
||||
await fs.rename(params.targetDir, backupDir);
|
||||
}
|
||||
|
||||
@@ -72,11 +85,26 @@ export async function installPackageDir(params: {
|
||||
if (!backupDir) {
|
||||
return;
|
||||
}
|
||||
await assertCanonicalPathWithinBase({
|
||||
baseDir: installBaseDir,
|
||||
candidatePath: params.targetDir,
|
||||
boundaryLabel: "install directory",
|
||||
});
|
||||
await assertCanonicalPathWithinBase({
|
||||
baseDir: installBaseDir,
|
||||
candidatePath: backupDir,
|
||||
boundaryLabel: "install directory",
|
||||
});
|
||||
await fs.rm(params.targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.rename(backupDir, params.targetDir).catch(() => undefined);
|
||||
};
|
||||
|
||||
try {
|
||||
await assertCanonicalPathWithinBase({
|
||||
baseDir: installBaseDir,
|
||||
candidatePath: params.targetDir,
|
||||
boundaryLabel: "install directory",
|
||||
});
|
||||
await fs.cp(params.sourceDir, params.targetDir, { recursive: true });
|
||||
} catch (err) {
|
||||
await rollback();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { safePathSegmentHashed } from "./install-safe-path.js";
|
||||
import { assertCanonicalPathWithinBase, safePathSegmentHashed } from "./install-safe-path.js";
|
||||
|
||||
describe("safePathSegmentHashed", () => {
|
||||
it("keeps safe names unchanged", () => {
|
||||
@@ -20,3 +23,44 @@ describe("safePathSegmentHashed", () => {
|
||||
expect(result).toMatch(/-[a-f0-9]{10}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertCanonicalPathWithinBase", () => {
|
||||
it("accepts in-base directories", async () => {
|
||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-safe-"));
|
||||
try {
|
||||
const candidate = path.join(baseDir, "tools");
|
||||
await fs.mkdir(candidate, { recursive: true });
|
||||
await expect(
|
||||
assertCanonicalPathWithinBase({
|
||||
baseDir,
|
||||
candidatePath: candidate,
|
||||
boundaryLabel: "install directory",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects symlinked candidate directories that escape the base",
|
||||
async () => {
|
||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-safe-"));
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-safe-outside-"));
|
||||
try {
|
||||
const linkDir = path.join(baseDir, "alias");
|
||||
await fs.symlink(outsideDir, linkDir);
|
||||
await expect(
|
||||
assertCanonicalPathWithinBase({
|
||||
baseDir,
|
||||
candidatePath: linkDir,
|
||||
boundaryLabel: "install directory",
|
||||
}),
|
||||
).rejects.toThrow(/must stay within install directory/i);
|
||||
} finally {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isPathInside } from "./path-guards.js";
|
||||
|
||||
export function unscopedPackageName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
@@ -60,3 +62,43 @@ export function resolveSafeInstallDir(params: {
|
||||
}
|
||||
return { ok: true, path: targetDir };
|
||||
}
|
||||
|
||||
export async function assertCanonicalPathWithinBase(params: {
|
||||
baseDir: string;
|
||||
candidatePath: string;
|
||||
boundaryLabel: string;
|
||||
}): Promise<void> {
|
||||
const baseDir = path.resolve(params.baseDir);
|
||||
const candidatePath = path.resolve(params.candidatePath);
|
||||
if (!isPathInside(baseDir, candidatePath)) {
|
||||
throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
|
||||
}
|
||||
|
||||
const baseLstat = await fs.lstat(baseDir);
|
||||
if (!baseLstat.isDirectory() || baseLstat.isSymbolicLink()) {
|
||||
throw new Error(`Invalid ${params.boundaryLabel}: base directory must be a real directory`);
|
||||
}
|
||||
const baseRealPath = await fs.realpath(baseDir);
|
||||
|
||||
const validateDirectory = async (dirPath: string): Promise<void> => {
|
||||
const dirLstat = await fs.lstat(dirPath);
|
||||
if (!dirLstat.isDirectory() || dirLstat.isSymbolicLink()) {
|
||||
throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
|
||||
}
|
||||
const dirRealPath = await fs.realpath(dirPath);
|
||||
if (!isPathInside(baseRealPath, dirRealPath)) {
|
||||
throw new Error(`Invalid path: must stay within ${params.boundaryLabel}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await validateDirectory(candidatePath);
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await validateDirectory(path.dirname(candidatePath));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user