diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index 860ab65c44..f35caae6ba 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -211,7 +211,7 @@ Outbound attachments from the agent: include `MEDIA:` on its own li ``` Here’s the screenshot. -MEDIA:/tmp/screenshot.png +MEDIA:https://example.com/screenshot.png ``` OpenClaw extracts these and sends them as media alongside the text. diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.security.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.security.test.ts new file mode 100644 index 0000000000..cd17bb340a --- /dev/null +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.security.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import { basename, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import type { MsgContext, TemplateContext } from "../templating.js"; + +const sandboxMocks = vi.hoisted(() => ({ + ensureSandboxWorkspaceForSession: vi.fn(), +})); + +vi.mock("../agents/sandbox.js", () => sandboxMocks); + +import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; +import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; + +async function withTempHome(fn: (home: string) => Promise): Promise { + return withTempHomeBase(async (home) => await fn(home), { prefix: "openclaw-triggers-bypass-" }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("stageSandboxMedia security", () => { + it("rejects staging host files from outside the media directory", async () => { + await withTempHome(async (home) => { + // Sensitive host file outside .openclaw + const sensitiveFile = join(home, "secrets.txt"); + await fs.writeFile(sensitiveFile, "SENSITIVE DATA"); + + const sandboxDir = join(home, "sandboxes", "session"); + vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ + workspaceDir: sandboxDir, + containerWorkdir: "/work", + }); + + const ctx: MsgContext = { + Body: "hi", + From: "whatsapp:group:demo", + To: "+2000", + ChatType: "group", + Provider: "whatsapp", + MediaPath: sensitiveFile, + MediaType: "image/jpeg", + MediaUrl: sensitiveFile, + }; + const sessionCtx: TemplateContext = { ...ctx }; + + // This should fail or skip the file + await stageSandboxMedia({ + ctx, + sessionCtx, + cfg: { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "openclaw"), + sandbox: { + mode: "non-main", + workspaceRoot: join(home, "sandboxes"), + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: join(home, "sessions.json") }, + }, + sessionKey: "agent:main:main", + workspaceDir: join(home, "openclaw"), + }); + + const stagedFullPath = join(sandboxDir, "media", "inbound", basename(sensitiveFile)); + // Expect the file NOT to be staged + await expect(fs.stat(stagedFullPath)).rejects.toThrow(); + + // Context should NOT be rewritten to a sandbox path if it failed to stage + expect(ctx.MediaPath).toBe(sensitiveFile); + }); + }); +}); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 19c4df49b2..d9c7d19cd0 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -248,7 +248,7 @@ export async function runPreparedReply( const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); const mediaNote = buildInboundMediaNote(ctx); const mediaReplyHint = mediaNote - ? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body." + ? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg (spaces ok, quote if needed) or a safe relative path like MEDIA:./image.jpg. Avoid absolute paths (MEDIA:/...) and ~ paths — they are blocked for security. Keep caption in the text body." : undefined; let prefixedCommandBody = mediaNote ? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim() diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index d2a910249e..43d289da5e 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -2,9 +2,11 @@ import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +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 { getMediaDir } from "../../media/store.js"; import { CONFIG_DIR } from "../../utils.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -80,6 +82,21 @@ export async function stageSandboxMedia(params: { continue; } + // Local paths must be restricted to the media directory. + if (!ctx.MediaRemoteHost) { + const mediaDir = getMediaDir(); + try { + await assertSandboxPath({ + filePath: source, + cwd: mediaDir, + root: mediaDir, + }); + } catch { + logVerbose(`Blocking attempt to stage media from outside media directory: ${source}`); + continue; + } + } + const baseName = path.basename(source); if (!baseName) { continue;