fix: openclaw allows normal reply text to carry media (#345) (#62136)

* fix: openclaw allows normal reply text to carry media (#345)
This commit is contained in:
Devin Robison
2026-04-06 16:08:33 -06:00
committed by GitHub
parent 8e3c597e80
commit e420468ebd
5 changed files with 174 additions and 38 deletions

View File

@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
- Auto-reply/media: block arbitrary absolute host-local `MEDIA:` paths from normal reply text, keep managed generated-media reply paths working, and require detected PDF or Office file content before host-local document sends are accepted.
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.

View File

@@ -18,6 +18,7 @@ describe("createReplyMediaPathNormalizer", () => {
beforeEach(() => {
ensureSandboxWorkspaceForSession.mockReset().mockResolvedValue(null);
saveMediaSource.mockReset();
vi.unstubAllEnvs();
});
it("resolves workspace-relative media against the agent workspace", async () => {
@@ -61,7 +62,7 @@ describe("createReplyMediaPathNormalizer", () => {
});
});
it("keeps host-local media paths flexible when sandbox exists and workspaceOnly is off", async () => {
it("drops arbitrary host-local media paths when sandbox exists", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
@@ -77,13 +78,13 @@ describe("createReplyMediaPathNormalizer", () => {
});
expect(result).toMatchObject({
mediaUrl: "/Users/peter/.openclaw/media/inbound/photo.png",
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(saveMediaSource).not.toHaveBeenCalled();
});
it("keeps sandbox media strict when workspaceOnly is enabled", async () => {
it("drops relative sandbox escapes when tools.fs.workspaceOnly is enabled", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
@@ -94,11 +95,59 @@ describe("createReplyMediaPathNormalizer", () => {
workspaceDir: "/tmp/agent-workspace",
});
await expect(
normalize({
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
}),
).rejects.toThrow(/sandbox root|outside|escapes/i);
const result = await normalize({
mediaUrls: ["../sandboxes/session-1/screens/final.png"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(saveMediaSource).not.toHaveBeenCalled();
});
it("keeps managed generated media under the shared media root", async () => {
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
});
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
});
expect(result).toMatchObject({
mediaUrl: "/Users/peter/.openclaw/media/tool-image-generation/generated.png",
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
});
expect(saveMediaSource).not.toHaveBeenCalled();
});
it("drops absolute file URLs outside managed reply media roots", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
});
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["file:///Users/peter/.openclaw/media/inbound/photo.png"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
});
it("persists volatile agent-state media from the workspace into host outbound media", async () => {

View File

@@ -8,6 +8,7 @@ import { resolveEffectiveToolFsWorkspaceOnly } from "../../agents/tool-fs-policy
import type { OpenClawConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { saveMediaSource } from "../../media/store.js";
import { resolveConfigDir } from "../../utils.js";
import type { ReplyPayload } from "../types.js";
const HTTP_URL_RE = /^https?:\/\//i;
@@ -16,12 +17,37 @@ const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
const HAS_FILE_EXT_RE = /\.\w{1,10}$/;
const AGENT_STATE_MEDIA_DIRNAME = path.join(".openclaw", "media");
const MANAGED_GLOBAL_MEDIA_SUBDIRS = new Set(["outbound"]);
function isPathInside(root: string, candidate: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isManagedGlobalReplyMediaPath(candidate: string): boolean {
const globalMediaRoot = path.join(resolveConfigDir(), "media");
const relative = path.relative(path.resolve(globalMediaRoot), path.resolve(candidate));
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
return false;
}
const firstSegment = relative.split(path.sep)[0] ?? "";
return MANAGED_GLOBAL_MEDIA_SUBDIRS.has(firstSegment) || firstSegment.startsWith("tool-");
}
function isAllowedAbsoluteReplyMediaPath(params: {
candidate: string;
workspaceDir: string;
sandboxRoot?: string;
}): boolean {
if (isManagedGlobalReplyMediaPath(params.candidate)) {
return true;
}
const volatileRoots = [params.workspaceDir, params.sandboxRoot]
.filter((root): root is string => Boolean(root))
.map((root) => path.join(path.resolve(root), AGENT_STATE_MEDIA_DIRNAME));
return volatileRoots.some((root) => isPathInside(root, params.candidate));
}
function isLikelyLocalMediaSource(media: string): boolean {
return (
FILE_URL_RE.test(media) ||
@@ -113,22 +139,53 @@ export function createReplyMediaPathNormalizer(params: {
sandboxRoot,
});
} catch (err) {
if (workspaceOnly || !isLikelyLocalMediaSource(media)) {
if (!isLikelyLocalMediaSource(media) || FILE_URL_RE.test(media)) {
throw err;
}
if (FILE_URL_RE.test(media)) {
if (workspaceOnly) {
throw err;
}
if (!path.isAbsolute(media)) {
return resolvePathFromInput(media, params.workspaceDir);
}
if (
isAllowedAbsoluteReplyMediaPath({
candidate: media,
workspaceDir: params.workspaceDir,
sandboxRoot,
})
) {
return media;
}
return resolvePathFromInput(media, params.workspaceDir);
throw new Error(
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
{ cause: err },
);
}
}
if (!isLikelyLocalMediaSource(media)) {
return media;
}
if (FILE_URL_RE.test(media)) {
throw new Error(
"Absolute host-local MEDIA file URLs are blocked in normal replies. Use a safe relative path or the message tool.",
);
}
if (!path.isAbsolute(media)) {
return resolvePathFromInput(media, params.workspaceDir);
}
if (
isAllowedAbsoluteReplyMediaPath({
candidate: media,
workspaceDir: params.workspaceDir,
sandboxRoot,
})
) {
return media;
}
return resolvePathFromInput(media, params.workspaceDir);
throw new Error(
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
);
};
return async (payload) => {
@@ -140,7 +197,13 @@ export function createReplyMediaPathNormalizer(params: {
const normalizedMedia: string[] = [];
const seen = new Set<string>();
for (const media of mediaList) {
const normalized = await persistVolatileAgentMedia(await normalizeMediaSource(media));
let normalized: string;
try {
normalized = await persistVolatileAgentMedia(await normalizeMediaSource(media));
} catch (err) {
logVerbose(`dropping blocked reply media ${media}: ${String(err)}`);
continue;
}
if (!normalized || seen.has(normalized)) {
continue;
}

View File

@@ -16,15 +16,24 @@ const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
let fixtureRoot = "";
let fakePdfFile = "";
let oversizedJpegFile = "";
let realPdfFile = "";
let tinyPngFile = "";
beforeAll(async () => {
({ loadWebMedia } = await import("./web-media.js"));
await mediaRootTracker.setup();
fixtureRoot = await mediaRootTracker.make("case");
fakePdfFile = path.join(fixtureRoot, "fake.pdf");
realPdfFile = path.join(fixtureRoot, "real.pdf");
tinyPngFile = path.join(fixtureRoot, "tiny.png");
oversizedJpegFile = path.join(fixtureRoot, "oversized.jpg");
await fs.writeFile(fakePdfFile, "TOP_SECRET_TEXT", "utf8");
await fs.writeFile(
realPdfFile,
Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF"),
);
await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
await fs.writeFile(
oversizedJpegFile,
@@ -175,4 +184,30 @@ describe("loadWebMedia", () => {
expect(result.buffer.length).toBeGreaterThan(0);
});
});
describe("host read capability", () => {
it("rejects document uploads that only match by file extension", async () => {
await expect(
loadWebMedia(fakePdfFile, {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
hostReadCapability: true,
}),
).rejects.toMatchObject({
code: "path-not-allowed",
});
});
it("still allows real PDF uploads detected from file content", async () => {
const result = await loadWebMedia(realPdfFile, {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
hostReadCapability: true,
});
expect(result.kind).toBe("document");
expect(result.contentType).toBe("application/pdf");
expect(result.fileName).toBe("real.pdf");
});
});
});

View File

@@ -78,15 +78,6 @@ const HOST_READ_ALLOWED_DOCUMENT_MIMES = new Set([
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]);
const HOST_READ_ALLOWED_DOCUMENT_EXTS = new Set([
".doc",
".docx",
".pdf",
".ppt",
".pptx",
".xls",
".xlsx",
]);
const MB = 1024 * 1024;
function formatMb(bytes: number, digits = 2): string {
@@ -121,23 +112,20 @@ function isHeicSource(opts: { contentType?: string; fileName?: string }): boolea
function assertHostReadMediaAllowed(params: {
contentType?: string;
kind: MediaKind | undefined;
mediaUrl: string;
fileName?: string;
}): void {
if (params.kind !== "document") {
if (params.kind === "image" || params.kind === "audio" || params.kind === "video") {
return;
}
if (params.kind !== "document") {
throw new LocalMediaAccessError(
"path-not-allowed",
`Host-local media sends only allow images, audio, video, PDF, and Office documents (got ${params.contentType?.trim().toLowerCase() ?? "unknown"}).`,
);
}
const normalizedMime = params.contentType?.trim().toLowerCase();
if (normalizedMime && HOST_READ_ALLOWED_DOCUMENT_MIMES.has(normalizedMime)) {
return;
}
const ext = path
.extname(params.fileName ?? params.mediaUrl)
.trim()
.toLowerCase();
if (ext && HOST_READ_ALLOWED_DOCUMENT_EXTS.has(ext)) {
return;
}
throw new LocalMediaAccessError(
"path-not-allowed",
`Host-local media sends only allow images, audio, video, PDF, and Office documents (got ${normalizedMime ?? "unknown"}).`,
@@ -381,7 +369,9 @@ async function loadWebMediaInternal(
throw err;
}
}
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
const detectedMime = await detectMime({ buffer: data, filePath: mediaUrl });
const verifiedMime = hostReadCapability ? await detectMime({ buffer: data }) : detectedMime;
const mime = verifiedMime ?? detectedMime;
const kind = kindFromMime(mime);
let fileName = path.basename(mediaUrl) || undefined;
if (fileName && !path.extname(fileName) && mime) {
@@ -392,10 +382,8 @@ async function loadWebMediaInternal(
}
if (hostReadCapability) {
assertHostReadMediaAllowed({
contentType: mime,
kind,
mediaUrl,
fileName,
contentType: verifiedMime,
kind: kindFromMime(detectedMime ?? verifiedMime),
});
}
return await clampAndFinalize({