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

@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js";
import { sanitizeUntrustedFileName } from "./safe-filename.js";
function buildSiblingTempPath(targetPath: string): string {
@@ -10,15 +11,31 @@ function buildSiblingTempPath(targetPath: string): string {
}
export async function writeViaSiblingTempPath(params: {
rootDir: string;
targetPath: string;
writeTemp: (tempPath: string) => Promise<void>;
}): Promise<void> {
const rootDir = path.resolve(params.rootDir);
const targetPath = path.resolve(params.targetPath);
const relativeTargetPath = path.relative(rootDir, targetPath);
if (
!relativeTargetPath ||
relativeTargetPath === ".." ||
relativeTargetPath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeTargetPath)
) {
throw new Error("Target path is outside the allowed root");
}
const tempPath = buildSiblingTempPath(targetPath);
let renameSucceeded = false;
try {
await params.writeTemp(tempPath);
await fs.rename(tempPath, targetPath);
await writeFileFromPathWithinRoot({
rootDir,
relativePath: relativeTargetPath,
sourcePath: tempPath,
mkdir: false,
});
renameSucceeded = true;
} finally {
if (!renameSucceeded) {

View File

@@ -4,7 +4,11 @@ import path from "node:path";
import type { Page } from "playwright-core";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { writeViaSiblingTempPath } from "./output-atomic.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
DEFAULT_DOWNLOAD_DIR,
DEFAULT_UPLOAD_DIR,
resolveStrictExistingPathsWithinRoot,
} from "./paths.js";
import {
ensurePageState,
getPageForTargetId,
@@ -92,6 +96,7 @@ async function saveDownloadPayload(download: DownloadPayload, outPath: string) {
await download.saveAs?.(resolvedOutPath);
} else {
await writeViaSiblingTempPath({
rootDir: DEFAULT_DOWNLOAD_DIR,
targetPath: resolvedOutPath,
writeTemp: async (tempPath) => {
await download.saveAs?.(tempPath);

View File

@@ -1,4 +1,5 @@
import { writeViaSiblingTempPath } from "./output-atomic.js";
import { DEFAULT_TRACE_DIR } from "./paths.js";
import { ensureContextState, getPageForTargetId } from "./pw-session.js";
export async function traceStartViaPlaywright(opts: {
@@ -34,6 +35,7 @@ export async function traceStopViaPlaywright(opts: {
throw new Error("No active trace. Start a trace before stopping it.");
}
await writeViaSiblingTempPath({
rootDir: DEFAULT_TRACE_DIR,
targetPath: opts.path,
writeTemp: async (tempPath) => {
await context.tracing.stop({ path: tempPath });