mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 14:04:58 +00:00
fix(security): harden sandbox media staging destination writes
This commit is contained in:
@@ -245,6 +245,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
|
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
|
||||||
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
|
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
|
||||||
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
|
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||||
|
- Security/Sandbox media staging: block destination symlink escapes in `stageSandboxMedia` by replacing direct destination copies with root-scoped safe writes for both local and SCP-staged attachments, preventing out-of-workspace file overwrite through `media/inbound` alias traversal. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||||
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
|
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
|
||||||
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
|
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
|
||||||
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||||
|
|||||||
@@ -101,4 +101,44 @@ describe("stageSandboxMedia", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks destination symlink escapes when staging into sandbox workspace", async () => {
|
||||||
|
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
|
||||||
|
const cfg = createSandboxMediaStageConfig(home);
|
||||||
|
const workspaceDir = join(home, "openclaw");
|
||||||
|
const sandboxDir = join(home, "sandboxes", "session");
|
||||||
|
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
|
||||||
|
workspaceDir: sandboxDir,
|
||||||
|
containerWorkdir: "/work",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inboundDir = join(home, ".openclaw", "media", "inbound");
|
||||||
|
await fs.mkdir(inboundDir, { recursive: true });
|
||||||
|
const mediaPath = join(inboundDir, "payload.txt");
|
||||||
|
await fs.writeFile(mediaPath, "PAYLOAD");
|
||||||
|
|
||||||
|
const outsideDir = join(home, "outside");
|
||||||
|
const outsideInboundDir = join(outsideDir, "inbound");
|
||||||
|
await fs.mkdir(outsideInboundDir, { recursive: true });
|
||||||
|
const victimPath = join(outsideDir, "victim.txt");
|
||||||
|
await fs.writeFile(victimPath, "ORIGINAL");
|
||||||
|
|
||||||
|
await fs.mkdir(sandboxDir, { recursive: true });
|
||||||
|
await fs.symlink(outsideDir, join(sandboxDir, "media"));
|
||||||
|
await fs.symlink(victimPath, join(outsideInboundDir, basename(mediaPath)));
|
||||||
|
|
||||||
|
const { ctx, sessionCtx } = createSandboxMediaContexts(mediaPath);
|
||||||
|
await stageSandboxMedia({
|
||||||
|
ctx,
|
||||||
|
sessionCtx,
|
||||||
|
cfg,
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.readFile(victimPath, "utf8")).resolves.toBe("ORIGINAL");
|
||||||
|
expect(ctx.MediaPath).toBe(mediaPath);
|
||||||
|
expect(sessionCtx.MediaPath).toBe(mediaPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { assertSandboxPath } from "../../agents/sandbox-paths.js";
|
|||||||
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { readLocalFileSafely, writeFileWithinRoot } from "../../infra/fs-safe.js";
|
||||||
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
|
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
|
||||||
import {
|
import {
|
||||||
isInboundPathAllowed,
|
isInboundPathAllowed,
|
||||||
resolveIMessageRemoteAttachmentRoots,
|
resolveIMessageRemoteAttachmentRoots,
|
||||||
@@ -69,11 +71,7 @@ export async function stageSandboxMedia(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For sandbox: <workspace>/media/inbound, for remote cache: use dir directly
|
await fs.mkdir(effectiveWorkspaceDir, { recursive: true });
|
||||||
const destDir = sandbox
|
|
||||||
? path.join(effectiveWorkspaceDir, "media", "inbound")
|
|
||||||
: effectiveWorkspaceDir;
|
|
||||||
await fs.mkdir(destDir, { recursive: true });
|
|
||||||
const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
|
const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: ctx.AccountId,
|
accountId: ctx.AccountId,
|
||||||
@@ -139,12 +137,22 @@ export async function stageSandboxMedia(params: {
|
|||||||
}
|
}
|
||||||
usedNames.add(fileName);
|
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) {
|
if (ctx.MediaRemoteHost) {
|
||||||
// Always use SCP when remote host is configured - local paths refer to remote machine
|
// Remote media arrives via SCP to a temp file, then we write into root with alias guards.
|
||||||
await scpFile(ctx.MediaRemoteHost, source, dest);
|
await stageRemoteFileIntoRoot({
|
||||||
|
remoteHost: ctx.MediaRemoteHost,
|
||||||
|
remotePath: source,
|
||||||
|
rootDir: effectiveWorkspaceDir,
|
||||||
|
relativeDestPath: relativeDest,
|
||||||
|
});
|
||||||
} else {
|
} 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
|
// For sandbox use relative path, for remote cache use absolute path
|
||||||
const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest;
|
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> {
|
async function scpFile(remoteHost: string, remotePath: string, localPath: string): Promise<void> {
|
||||||
const safeRemoteHost = normalizeScpRemoteHost(remoteHost);
|
const safeRemoteHost = normalizeScpRemoteHost(remoteHost);
|
||||||
if (!safeRemoteHost) {
|
if (!safeRemoteHost) {
|
||||||
|
|||||||
Reference in New Issue
Block a user