fix(security): restrict MEDIA path extraction to prevent LFI (#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>
This commit is contained in:
Glucksberg
2026-01-31 14:55:37 -04:00
committed by GitHub
parent f1de88c198
commit 34e2425b4d
4 changed files with 98 additions and 2 deletions

View File

@@ -211,7 +211,7 @@ Outbound attachments from the agent: include `MEDIA:<path-or-url>` on its own li
```
Heres the screenshot.
MEDIA:/tmp/screenshot.png
MEDIA:https://example.com/screenshot.png
```
OpenClaw extracts these and sends them as media alongside the text.

View File

@@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
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);
});
});
});

View File

@@ -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()

View File

@@ -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;