fix(security): harden sandbox media staging destination writes

This commit is contained in:
Peter Steinberger
2026-03-02 16:34:59 +00:00
parent be65dc8acc
commit 17ede52a4b
3 changed files with 93 additions and 9 deletions

View File

@@ -6,7 +6,9 @@ import { assertSandboxPath } from "../../agents/sandbox-paths.js";
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
import type { OpenClawConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { readLocalFileSafely, writeFileWithinRoot } from "../../infra/fs-safe.js";
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import {
isInboundPathAllowed,
resolveIMessageRemoteAttachmentRoots,
@@ -69,11 +71,7 @@ export async function stageSandboxMedia(params: {
};
try {
// For sandbox: <workspace>/media/inbound, for remote cache: use dir directly
const destDir = sandbox
? path.join(effectiveWorkspaceDir, "media", "inbound")
: effectiveWorkspaceDir;
await fs.mkdir(destDir, { recursive: true });
await fs.mkdir(effectiveWorkspaceDir, { recursive: true });
const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
cfg,
accountId: ctx.AccountId,
@@ -139,12 +137,22 @@ export async function stageSandboxMedia(params: {
}
usedNames.add(fileName);
const dest = path.join(destDir, fileName);
const relativeDest = sandbox ? path.join("media", "inbound", fileName) : fileName;
const dest = path.join(effectiveWorkspaceDir, relativeDest);
if (ctx.MediaRemoteHost) {
// Always use SCP when remote host is configured - local paths refer to remote machine
await scpFile(ctx.MediaRemoteHost, source, dest);
// Remote media arrives via SCP to a temp file, then we write into root with alias guards.
await stageRemoteFileIntoRoot({
remoteHost: ctx.MediaRemoteHost,
remotePath: source,
rootDir: effectiveWorkspaceDir,
relativeDestPath: relativeDest,
});
} else {
await fs.copyFile(source, dest);
await stageLocalFileIntoRoot({
sourcePath: source,
rootDir: effectiveWorkspaceDir,
relativeDestPath: relativeDest,
});
}
// For sandbox use relative path, for remote cache use absolute path
const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest;
@@ -193,6 +201,41 @@ export async function stageSandboxMedia(params: {
}
}
async function stageLocalFileIntoRoot(params: {
sourcePath: string;
rootDir: string;
relativeDestPath: string;
}): Promise<void> {
const safeRead = await readLocalFileSafely({ filePath: params.sourcePath });
await writeFileWithinRoot({
rootDir: params.rootDir,
relativePath: params.relativeDestPath,
data: safeRead.buffer,
});
}
async function stageRemoteFileIntoRoot(params: {
remoteHost: string;
remotePath: string;
rootDir: string;
relativeDestPath: string;
}): Promise<void> {
const tmpRoot = resolvePreferredOpenClawTmpDir();
await fs.mkdir(tmpRoot, { recursive: true });
const tmpDir = await fs.mkdtemp(path.join(tmpRoot, "stage-sandbox-media-"));
const tmpPath = path.join(tmpDir, "download");
try {
await scpFile(params.remoteHost, params.remotePath, tmpPath);
await stageLocalFileIntoRoot({
sourcePath: tmpPath,
rootDir: params.rootDir,
relativeDestPath: params.relativeDestPath,
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
async function scpFile(remoteHost: string, remotePath: string, localPath: string): Promise<void> {
const safeRemoteHost = normalizeScpRemoteHost(remoteHost);
if (!safeRemoteHost) {