Medium severity6.5NVD Advisory· Published Mar 31, 2026· Updated Apr 1, 2026
CVE-2026-33581
CVE-2026-33581
Description
OpenClaw before 2026.3.24 contains a sandbox bypass vulnerability in the message tool that allows attackers to read arbitrary local files by using mediaUrl and fileUrl alias parameters that bypass localRoots validation. Remote attackers can exploit this by routing file requests through unvalidated alias parameters to access files outside the intended sandbox directory.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.24 | 2026.3.24 |
Affected products
1Patches
11d7cb6fc0355fix: close sandbox media root bypass for mediaUrl/fileUrl aliases (#54034)
5 files changed · +282 −31
src/infra/outbound/message-action-params.test.ts+81 −0 modified@@ -185,6 +185,87 @@ describe("message action media helpers", () => { } }); + maybeIt("normalizes mediaUrl and fileUrl sandbox media params", async () => { + const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-alias-")); + try { + const args: Record<string, unknown> = { + mediaUrl: " file:///workspace/assets/photo.png ", + fileUrl: "/workspace/docs/report.pdf", + }; + + await normalizeSandboxMediaParams({ + args, + mediaPolicy: { + mode: "sandbox", + sandboxRoot: ` ${sandboxRoot} `, + }, + }); + + expect(args).toMatchObject({ + mediaUrl: path.join(sandboxRoot, "assets", "photo.png"), + fileUrl: path.join(sandboxRoot, "docs", "report.pdf"), + }); + } finally { + await fs.rm(sandboxRoot, { recursive: true, force: true }); + } + }); + + maybeIt( + "keeps remote HTTP mediaUrl and fileUrl aliases unchanged under sandbox normalization", + async () => { + const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-remote-alias-")); + try { + const args: Record<string, unknown> = { + mediaUrl: "https://example.com/assets/photo.png?sig=1", + fileUrl: "https://example.com/docs/report.pdf?sig=2", + }; + + await normalizeSandboxMediaParams({ + args, + mediaPolicy: { + mode: "sandbox", + sandboxRoot, + }, + }); + + expect(args).toMatchObject({ + mediaUrl: "https://example.com/assets/photo.png?sig=1", + fileUrl: "https://example.com/docs/report.pdf?sig=2", + }); + } finally { + await fs.rm(sandboxRoot, { recursive: true, force: true }); + } + }, + ); + + it("uses mediaUrl and fileUrl aliases when inferring attachment filenames", async () => { + const mediaArgs: Record<string, unknown> = { + mediaUrl: "https://example.com/pic.png", + }; + await hydrateAttachmentParamsForAction({ + cfg, + channel: "slack", + args: mediaArgs, + action: "sendAttachment", + dryRun: true, + mediaPolicy: { mode: "host" }, + }); + expect(mediaArgs.filename).toBe("pic.png"); + + const fileArgs: Record<string, unknown> = { + fileUrl: "https://example.com/docs/report.pdf", + }; + await hydrateAttachmentParamsForAction({ + cfg, + channel: "slack", + args: fileArgs, + action: "sendAttachment", + dryRun: true, + mediaPolicy: { mode: "host" }, + }); + expect(fileArgs.filename).toBe("report.pdf"); + }); + it("falls back to extension-based attachment names for remote-host file URLs", async () => { const args: Record<string, unknown> = { media: "file://attacker/share/photo.png",
src/infra/outbound/message-action-params.ts+25 −7 modified@@ -10,6 +10,27 @@ import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boo export const readBooleanParam = readBooleanParamShared; +const SANDBOX_MEDIA_PARAM_KEYS = ["media", "path", "filePath", "mediaUrl", "fileUrl"] as const; + +function readMediaParam( + args: Record<string, unknown>, + key: (typeof SANDBOX_MEDIA_PARAM_KEYS)[number], +): string | undefined { + return readStringParam(args, key, { trim: false }); +} + +function readAttachmentMediaHint(args: Record<string, unknown>): string | undefined { + return readMediaParam(args, "media") ?? readMediaParam(args, "mediaUrl"); +} + +function readAttachmentFileHint(args: Record<string, unknown>): string | undefined { + return ( + readMediaParam(args, "path") ?? + readMediaParam(args, "filePath") ?? + readMediaParam(args, "fileUrl") + ); +} + function resolveAttachmentMaxBytes(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -190,9 +211,8 @@ export async function normalizeSandboxMediaParams(params: { }): Promise<void> { const sandboxRoot = params.mediaPolicy.mode === "sandbox" ? params.mediaPolicy.sandboxRoot.trim() : undefined; - const mediaKeys: Array<"media" | "path" | "filePath"> = ["media", "path", "filePath"]; - for (const key of mediaKeys) { - const raw = readStringParam(params.args, key, { trim: false }); + for (const key of SANDBOX_MEDIA_PARAM_KEYS) { + const raw = readMediaParam(params.args, key); if (!raw) { continue; } @@ -242,10 +262,8 @@ async function hydrateAttachmentActionPayload(params: { allowMessageCaptionFallback?: boolean; mediaPolicy: AttachmentMediaPolicy; }): Promise<void> { - const mediaHint = readStringParam(params.args, "media", { trim: false }); - const fileHint = - readStringParam(params.args, "path", { trim: false }) ?? - readStringParam(params.args, "filePath", { trim: false }); + const mediaHint = readAttachmentMediaHint(params.args); + const fileHint = readAttachmentFileHint(params.args); const contentTypeParam = readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType");
src/infra/outbound/message-action-runner.media.test.ts+149 −22 modified@@ -56,6 +56,7 @@ const runDrySend = (params: { async function expectSandboxMediaRewrite(params: { sandboxDir: string; media?: string; + mediaField?: "media" | "mediaUrl" | "fileUrl"; message?: string; expectedRelativePath: string; }) { @@ -64,7 +65,11 @@ async function expectSandboxMediaRewrite(params: { actionParams: { channel: "slack", target: "#C12345678", - ...(params.media ? { media: params.media } : {}), + ...(params.media + ? { + [params.mediaField ?? "media"]: params.media, + } + : {}), ...(params.message ? { message: params.message } : {}), }, sandboxRoot: params.sandboxDir, @@ -196,6 +201,7 @@ describe("runMessageAction media behavior", () => { async function expectRejectsLocalAbsolutePathWithoutSandbox(params: { action: "sendAttachment" | "setGroupIcon"; target: string; + mediaField?: "media" | "mediaUrl" | "fileUrl"; message?: string; tempPrefix: string; }) { @@ -209,7 +215,7 @@ describe("runMessageAction media behavior", () => { const actionParams: Record<string, unknown> = { channel: "bluebubbles", target: params.target, - media: outsidePath, + [params.mediaField ?? "media"]: outsidePath, }; if (params.message) { actionParams.message = params.message; @@ -270,6 +276,24 @@ describe("runMessageAction media behavior", () => { message: "caption", expectedPath: path.join("data", "pic.png"), }, + { + name: "sendAttachment mediaUrl rewrite", + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "mediaUrl" as const, + media: "./data/pic.png", + message: "caption", + expectedPath: path.join("data", "pic.png"), + }, + { + name: "sendAttachment fileUrl rewrite", + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "fileUrl" as const, + media: "/workspace/files/report.pdf", + message: "caption", + expectedPath: path.join("files", "report.pdf"), + }, { name: "setGroupIcon rewrite", action: "setGroupIcon" as const, @@ -286,7 +310,7 @@ describe("runMessageAction media behavior", () => { params: { channel: "bluebubbles", target: testCase.target, - media: testCase.media, + [testCase.mediaField ?? "media"]: testCase.media, ...(testCase.message ? { message: testCase.message } : {}), }, sandboxRoot: sandboxDir, @@ -309,6 +333,20 @@ describe("runMessageAction media behavior", () => { message: "caption", tempPrefix: "msg-attachment-", }, + { + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "mediaUrl" as const, + message: "caption", + tempPrefix: "msg-attachment-media-url-", + }, + { + action: "sendAttachment" as const, + target: "+15551234567", + mediaField: "fileUrl" as const, + message: "caption", + tempPrefix: "msg-attachment-file-url-", + }, { action: "setGroupIcon" as const, target: "group:123", @@ -337,25 +375,43 @@ describe("runMessageAction media behavior", () => { setActivePluginRegistry(createTestRegistry([])); }); - it.each(["/etc/passwd", "file:///etc/passwd"])( - "rejects out-of-sandbox media reference: %s", - async (media) => { - await withSandbox(async (sandboxDir) => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - media, - message: "", - }, - sandboxRoot: sandboxDir, - }), - ).rejects.toThrow(/sandbox/i); - }); + it.each([ + { + name: "media absolute path", + mediaField: "media" as const, + media: "/etc/passwd", }, - ); + { + name: "mediaUrl absolute path", + mediaField: "mediaUrl" as const, + media: "/etc/passwd", + }, + { + name: "mediaUrl file URL", + mediaField: "mediaUrl" as const, + media: "file:///etc/passwd", + }, + { + name: "fileUrl file URL", + mediaField: "fileUrl" as const, + media: "file:///etc/passwd", + }, + ])("rejects out-of-sandbox media reference: $name", async ({ mediaField, media }) => { + await withSandbox(async (sandboxDir) => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + [mediaField]: media, + message: "", + }, + sandboxRoot: sandboxDir, + }), + ).rejects.toThrow(/sandbox/i); + }); + }); it("rejects data URLs in media params", async () => { await expect( @@ -379,6 +435,20 @@ describe("runMessageAction media behavior", () => { message: "", expectedRelativePath: path.join("data", "file.txt"), }, + { + name: "relative mediaUrl path", + mediaField: "mediaUrl" as const, + media: "./data/file.txt", + message: "", + expectedRelativePath: path.join("data", "file.txt"), + }, + { + name: "/workspace fileUrl path", + mediaField: "fileUrl" as const, + media: "/workspace/data/file.txt", + message: "", + expectedRelativePath: path.join("data", "file.txt"), + }, { name: "/workspace media path", media: "/workspace/data/file.txt", @@ -390,18 +460,75 @@ describe("runMessageAction media behavior", () => { message: "Hello\nMEDIA: ./data/note.ogg", expectedRelativePath: path.join("data", "note.ogg"), }, - ]) { + ] as const) { await withSandbox(async (sandboxDir) => { await expectSandboxMediaRewrite({ sandboxDir, media: testCase.media, + mediaField: testCase.mediaField, message: testCase.message, expectedRelativePath: testCase.expectedRelativePath, }); }); } }); + it("prefers media over mediaUrl when both aliases are present", async () => { + await withSandbox(async (sandboxDir) => { + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + media: "./data/primary.txt", + mediaUrl: "./data/secondary.txt", + message: "", + }, + sandboxRoot: sandboxDir, + }); + + expect(result.kind).toBe("send"); + if (result.kind !== "send") { + throw new Error("expected send result"); + } + expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "primary.txt")); + }); + }); + + it.each([ + { + name: "mediaUrl", + mediaField: "mediaUrl" as const, + }, + { + name: "fileUrl", + mediaField: "fileUrl" as const, + }, + ])( + "keeps remote HTTP $name aliases unchanged under sandbox validation", + async ({ mediaField }) => { + await withSandbox(async (sandboxDir) => { + const remoteUrl = "https://example.com/files/report.pdf?sig=1"; + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + [mediaField]: remoteUrl, + message: "", + }, + sandboxRoot: sandboxDir, + }); + + expect(result.kind).toBe("send"); + if (result.kind !== "send") { + throw new Error("expected send result"); + } + expect(result.sendResult?.mediaUrl).toBe(remoteUrl); + }); + }, + ); + it("allows media paths under preferred OpenClaw tmp root", async () => { const tmpRoot = resolvePreferredOpenClawTmpDir(); await fs.mkdir(tmpRoot, { recursive: true });
src/infra/outbound/message-action-runner.plugin-dispatch.test.ts+7 −0 modified@@ -1,3 +1,4 @@ +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; @@ -58,6 +59,7 @@ describe("runMessageAction plugin dispatch", () => { afterEach(() => { setActivePluginRegistry(createTestRegistry([])); vi.clearAllMocks(); + vi.unstubAllEnvs(); }); it("dispatches messageId/chatId-based Feishu actions through the shared runner", async () => { @@ -114,6 +116,10 @@ describe("runMessageAction plugin dispatch", () => { }); it("routes execution context ids into plugin handleAction", async () => { + const stateDir = path.join("/tmp", "openclaw-plugin-dispatch-media-roots"); + const expectedWorkspaceRoot = path.resolve(stateDir, "workspace-alpha"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + await runMessageAction({ cfg: { channels: { @@ -148,6 +154,7 @@ describe("runMessageAction plugin dispatch", () => { sessionKey: "agent:alpha:main", sessionId: "session-123", agentId: "alpha", + mediaLocalRoots: expect.arrayContaining([expectedWorkspaceRoot]), toolContext: expect.objectContaining({ currentChannelId: "chat:oc_123", currentThreadTs: "thread-456",
src/infra/outbound/message-action-runner.ts+20 −2 modified@@ -258,6 +258,7 @@ type ResolvedActionContext = { cfg: OpenClawConfig; params: Record<string, unknown>; channel: ChannelId; + mediaLocalRoots: readonly string[]; accountId?: string | null; dryRun: boolean; gateway?: MessageActionRunnerGateway; @@ -382,8 +383,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi // Support media, path, and filePath parameters for attachments const mediaHint = readStringParam(params, "media", { trim: false }) ?? + readStringParam(params, "mediaUrl", { trim: false }) ?? readStringParam(params, "path", { trim: false }) ?? - readStringParam(params, "filePath", { trim: false }); + readStringParam(params, "filePath", { trim: false }) ?? + readStringParam(params, "fileUrl", { trim: false }); const hasButtons = Array.isArray(params.buttons) && params.buttons.length > 0; const hasCard = params.card != null && typeof params.card === "object"; const hasComponents = params.components != null && typeof params.components === "object"; @@ -620,7 +623,18 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi } async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> { - const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal, agentId } = ctx; + const { + cfg, + params, + channel, + mediaLocalRoots, + accountId, + dryRun, + gateway, + input, + abortSignal, + agentId, + } = ctx; throwIfAborted(abortSignal); const action = input.action as Exclude<ChannelMessageActionName, "send" | "poll" | "broadcast">; if (dryRun) { @@ -644,6 +658,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc action, cfg, params, + mediaLocalRoots, accountId: accountId ?? undefined, requesterSenderId: input.requesterSenderId ?? undefined, sessionKey: input.sessionKey, @@ -753,6 +768,7 @@ export async function runMessageAction( cfg, params, channel, + mediaLocalRoots, accountId, dryRun, gateway, @@ -768,6 +784,7 @@ export async function runMessageAction( cfg, params, channel, + mediaLocalRoots, accountId, dryRun, gateway, @@ -780,6 +797,7 @@ export async function runMessageAction( cfg, params, channel, + mediaLocalRoots, accountId, dryRun, gateway,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/1d7cb6fc03552bbba00e7cffb3aa9741f5556416nvdPatchWEB
- github.com/advisories/GHSA-v8wv-jg3q-qwpqghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-v8wv-jg3q-qwpqnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-33581ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-arbitrary-file-read-via-mediaurl-and-fileurl-parametersnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.