Skip to content

Commit 34e2425

Browse files
fix(security): restrict MEDIA path extraction to prevent LFI (openclaw#4930)
* fix(security): restrict inbound media staging to media directory * docs: update MEDIA path guidance for security restrictions - Update agent hint to warn against absolute/~ paths - Update docs example to use https:// instead of /tmp/ --------- Co-authored-by: Evan Otero <evanotero@google.com>
1 parent f1de88c commit 34e2425

File tree

4 files changed

+98
-2
lines changed

4 files changed

+98
-2
lines changed

docs/start/openclaw.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ Outbound attachments from the agent: include `MEDIA:<path-or-url>` on its own li
211211

212212
```
213213
Here’s the screenshot.
214-
MEDIA:/tmp/screenshot.png
214+
MEDIA:https://example.com/screenshot.png
215215
```
216216

217217
OpenClaw extracts these and sends them as media alongside the text.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import fs from "node:fs/promises";
2+
import { basename, join } from "node:path";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
5+
import type { MsgContext, TemplateContext } from "../templating.js";
6+
7+
const sandboxMocks = vi.hoisted(() => ({
8+
ensureSandboxWorkspaceForSession: vi.fn(),
9+
}));
10+
11+
vi.mock("../agents/sandbox.js", () => sandboxMocks);
12+
13+
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
14+
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
15+
16+
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
17+
return withTempHomeBase(async (home) => await fn(home), { prefix: "openclaw-triggers-bypass-" });
18+
}
19+
20+
afterEach(() => {
21+
vi.restoreAllMocks();
22+
});
23+
24+
describe("stageSandboxMedia security", () => {
25+
it("rejects staging host files from outside the media directory", async () => {
26+
await withTempHome(async (home) => {
27+
// Sensitive host file outside .openclaw
28+
const sensitiveFile = join(home, "secrets.txt");
29+
await fs.writeFile(sensitiveFile, "SENSITIVE DATA");
30+
31+
const sandboxDir = join(home, "sandboxes", "session");
32+
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
33+
workspaceDir: sandboxDir,
34+
containerWorkdir: "/work",
35+
});
36+
37+
const ctx: MsgContext = {
38+
Body: "hi",
39+
From: "whatsapp:group:demo",
40+
To: "+2000",
41+
ChatType: "group",
42+
Provider: "whatsapp",
43+
MediaPath: sensitiveFile,
44+
MediaType: "image/jpeg",
45+
MediaUrl: sensitiveFile,
46+
};
47+
const sessionCtx: TemplateContext = { ...ctx };
48+
49+
// This should fail or skip the file
50+
await stageSandboxMedia({
51+
ctx,
52+
sessionCtx,
53+
cfg: {
54+
agents: {
55+
defaults: {
56+
model: "anthropic/claude-opus-4-5",
57+
workspace: join(home, "openclaw"),
58+
sandbox: {
59+
mode: "non-main",
60+
workspaceRoot: join(home, "sandboxes"),
61+
},
62+
},
63+
},
64+
channels: { whatsapp: { allowFrom: ["*"] } },
65+
session: { store: join(home, "sessions.json") },
66+
},
67+
sessionKey: "agent:main:main",
68+
workspaceDir: join(home, "openclaw"),
69+
});
70+
71+
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(sensitiveFile));
72+
// Expect the file NOT to be staged
73+
await expect(fs.stat(stagedFullPath)).rejects.toThrow();
74+
75+
// Context should NOT be rewritten to a sandbox path if it failed to stage
76+
expect(ctx.MediaPath).toBe(sensitiveFile);
77+
});
78+
});
79+
});

src/auto-reply/reply/get-reply-run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export async function runPreparedReply(
248248
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
249249
const mediaNote = buildInboundMediaNote(ctx);
250250
const mediaReplyHint = mediaNote
251-
? "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."
251+
? "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."
252252
: undefined;
253253
let prefixedCommandBody = mediaNote
254254
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()

src/auto-reply/reply/stage-sandbox-media.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { spawn } from "node:child_process";
22
import fs from "node:fs/promises";
33
import path from "node:path";
44
import { fileURLToPath } from "node:url";
5+
import { assertSandboxPath } from "../../agents/sandbox-paths.js";
56
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
67
import type { OpenClawConfig } from "../../config/config.js";
78
import { logVerbose } from "../../globals.js";
9+
import { getMediaDir } from "../../media/store.js";
810
import { CONFIG_DIR } from "../../utils.js";
911
import type { MsgContext, TemplateContext } from "../templating.js";
1012

@@ -80,6 +82,21 @@ export async function stageSandboxMedia(params: {
8082
continue;
8183
}
8284

85+
// Local paths must be restricted to the media directory.
86+
if (!ctx.MediaRemoteHost) {
87+
const mediaDir = getMediaDir();
88+
try {
89+
await assertSandboxPath({
90+
filePath: source,
91+
cwd: mediaDir,
92+
root: mediaDir,
93+
});
94+
} catch {
95+
logVerbose(`Blocking attempt to stage media from outside media directory: ${source}`);
96+
continue;
97+
}
98+
}
99+
83100
const baseName = path.basename(source);
84101
if (!baseName) {
85102
continue;

0 commit comments

Comments
 (0)