diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d3087921..887be686e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents: enforce sandbox validation for message media/file paths. (#6398) Thanks @leszekszpunar. - Docs: run oxfmt to fix format checks. (#6513) Thanks @app/clawdinator. - Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation. - Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso. diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 15d416fd89..2d3ecb7bae 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -187,6 +187,27 @@ describe("message tool sandbox path validation", () => { } }); + it("rejects filePath file URL that escapes sandbox root", async () => { + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); + try { + const tool = createMessageTool({ + config: {} as never, + sandboxRoot: sandboxDir, + }); + + await expect( + tool.execute("1", { + action: "send", + target: "telegram:123", + filePath: "file:///etc/passwd", + message: "", + }), + ).rejects.toThrow(/sandbox/i); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } + }); + it("rejects path param with traversal sequence", async () => { const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); try { @@ -208,6 +229,59 @@ describe("message tool sandbox path validation", () => { } }); + it("rejects media path that escapes sandbox root", async () => { + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); + try { + const tool = createMessageTool({ + config: {} as never, + sandboxRoot: sandboxDir, + }); + + await expect( + tool.execute("1", { + action: "send", + target: "telegram:123", + media: "/etc/passwd", + message: "", + }), + ).rejects.toThrow(/sandbox/i); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } + }); + + it("allows remote media URLs inside sandbox", async () => { + mocks.runMessageAction.mockClear(); + mocks.runMessageAction.mockResolvedValue({ + kind: "send", + action: "send", + channel: "telegram", + to: "telegram:123", + handledBy: "plugin", + payload: {}, + dryRun: true, + } satisfies MessageActionRunResult); + + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); + try { + const tool = createMessageTool({ + config: {} as never, + sandboxRoot: sandboxDir, + }); + + await tool.execute("1", { + action: "send", + target: "telegram:123", + media: "https://example.com/file.png", + message: "", + }); + + expect(mocks.runMessageAction).toHaveBeenCalledTimes(1); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } + }); + it("allows filePath inside sandbox root", async () => { mocks.runMessageAction.mockClear(); mocks.runMessageAction.mockResolvedValue({ diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 359b075d2d..f294dc718d 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,4 +1,5 @@ import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; import type { OpenClawConfig } from "../../config/config.js"; import type { AnyAgentTool } from "./common.js"; import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; @@ -24,6 +25,35 @@ import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema import { jsonResult, readNumberParam, readStringParam } from "./common.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; +const REMOTE_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i; + +function normalizeSandboxPathInput(raw: string): string { + if (!raw.startsWith("file://")) { + return raw; + } + try { + return fileURLToPath(raw); + } catch { + throw new Error(`Invalid file:// URL: ${raw}`); + } +} + +function shouldValidateMediaPath(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) { + return false; + } + if (trimmed.startsWith("file://")) { + return true; + } + if (REMOTE_SCHEME_PATTERN.test(trimmed)) { + return false; + } + return true; +} function buildRoutingSchema() { return { channel: Type.Optional(Type.String()), @@ -365,14 +395,22 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { }) as ChannelMessageActionName; // Validate file paths against sandbox root to prevent host file access. - const sandboxRoot = options?.sandboxRoot; + const sandboxRoot = options?.sandboxRoot?.trim(); if (sandboxRoot) { + const validatePath = async (raw: string) => { + const normalized = normalizeSandboxPathInput(raw); + await assertSandboxPath({ filePath: normalized, cwd: sandboxRoot, root: sandboxRoot }); + }; for (const key of ["filePath", "path"] as const) { const raw = readStringParam(params, key, { trim: false }); if (raw) { - await assertSandboxPath({ filePath: raw, cwd: sandboxRoot, root: sandboxRoot }); + await validatePath(raw); } } + const mediaRaw = readStringParam(params, "media", { trim: false }); + if (mediaRaw && shouldValidateMediaPath(mediaRaw)) { + await validatePath(mediaRaw); + } } const accountId = readStringParam(params, "accountId") ?? agentAccountId;