mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 18:26:37 +00:00
refactor(security): unify hardened install and fs write flows
This commit is contained in:
@@ -418,8 +418,11 @@ export async function copyFileWithinRoot(params: {
|
||||
relativePath: string;
|
||||
maxBytes?: number;
|
||||
mkdir?: boolean;
|
||||
rejectSourceHardlinks?: boolean;
|
||||
}): Promise<void> {
|
||||
const source = await openVerifiedLocalFile(params.sourcePath);
|
||||
const source = await openVerifiedLocalFile(params.sourcePath, {
|
||||
rejectHardlinks: params.rejectSourceHardlinks,
|
||||
});
|
||||
if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
|
||||
await source.handle.close().catch(() => {});
|
||||
throw new SafeOpenError(
|
||||
@@ -471,108 +474,11 @@ export async function writeFileFromPathWithinRoot(params: {
|
||||
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(() => {});
|
||||
}
|
||||
await copyFileWithinRoot({
|
||||
sourcePath: params.sourcePath,
|
||||
rootDir: params.rootDir,
|
||||
relativePath: params.relativePath,
|
||||
mkdir: params.mkdir,
|
||||
rejectSourceHardlinks: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +49,19 @@ async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
|
||||
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
async function assertInstallBoundaryPaths(params: {
|
||||
installBaseDir: string;
|
||||
candidatePaths: string[];
|
||||
}): Promise<void> {
|
||||
for (const candidatePath of params.candidatePaths) {
|
||||
await assertCanonicalPathWithinBase({
|
||||
baseDir: params.installBaseDir,
|
||||
candidatePath,
|
||||
boundaryLabel: "install directory",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function installPackageDir(params: {
|
||||
sourceDir: string;
|
||||
targetDir: string;
|
||||
@@ -63,20 +76,18 @@ export async function installPackageDir(params: {
|
||||
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",
|
||||
await assertInstallBoundaryPaths({
|
||||
installBaseDir,
|
||||
candidatePaths: [params.targetDir],
|
||||
});
|
||||
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 assertInstallBoundaryPaths({
|
||||
installBaseDir,
|
||||
candidatePaths: [backupDir],
|
||||
});
|
||||
await fs.rename(params.targetDir, backupDir);
|
||||
}
|
||||
@@ -85,25 +96,18 @@ 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 assertInstallBoundaryPaths({
|
||||
installBaseDir,
|
||||
candidatePaths: [params.targetDir, backupDir],
|
||||
});
|
||||
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 assertInstallBoundaryPaths({
|
||||
installBaseDir,
|
||||
candidatePaths: [params.targetDir],
|
||||
});
|
||||
await fs.cp(params.sourceDir, params.targetDir, { recursive: true });
|
||||
} catch (err) {
|
||||
|
||||
41
src/infra/install-target.ts
Normal file
41
src/infra/install-target.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { fileExists } from "./archive.js";
|
||||
import { assertCanonicalPathWithinBase, resolveSafeInstallDir } from "./install-safe-path.js";
|
||||
|
||||
export async function resolveCanonicalInstallTarget(params: {
|
||||
baseDir: string;
|
||||
id: string;
|
||||
invalidNameMessage: string;
|
||||
boundaryLabel: string;
|
||||
}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> {
|
||||
await fs.mkdir(params.baseDir, { recursive: true });
|
||||
const targetDirResult = resolveSafeInstallDir({
|
||||
baseDir: params.baseDir,
|
||||
id: params.id,
|
||||
invalidNameMessage: params.invalidNameMessage,
|
||||
});
|
||||
if (!targetDirResult.ok) {
|
||||
return { ok: false, error: targetDirResult.error };
|
||||
}
|
||||
try {
|
||||
await assertCanonicalPathWithinBase({
|
||||
baseDir: params.baseDir,
|
||||
candidatePath: targetDirResult.path,
|
||||
boundaryLabel: params.boundaryLabel,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
return { ok: true, targetDir: targetDirResult.path };
|
||||
}
|
||||
|
||||
export async function ensureInstallTargetAvailable(params: {
|
||||
mode: "install" | "update";
|
||||
targetDir: string;
|
||||
alreadyExistsError: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (params.mode === "install" && (await fileExists(params.targetDir))) {
|
||||
return { ok: false, error: params.alreadyExistsError };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user