diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts index a21169bc95c2..48bb08bbaaa2 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts @@ -662,4 +662,31 @@ describe("trySendViaHostRead error handling", () => { }), ); }); + + it.each([ + ["single-encoded", "%2e%2e%2f".repeat(5) + "escape.mp3"], + ["double-encoded", "%252e%252e%252f".repeat(5) + "escape.mp3"], + ])("confines %s host-read voice filenames to the staging root", async (_label, fileName) => { + mockedLoadOutboundMediaFromUrl.mockResolvedValue({ + buffer: Buffer.from("audio bytes"), + kind: "audio", + fileName, + contentType: "audio/mpeg", + }); + mockedSenderSendMedia.mockResolvedValue({ id: "voice-1", timestamp: 123 }); + + const result = await sendVoice(makeCtx(), "clip.mp3", [".mp3"], true); + + expect(result).toMatchObject({ channel: "qqbot", messageId: "voice-1" }); + const stagedPath = mockedSenderSendMedia.mock.calls[0]?.[0].localPathForMeta; + expect(stagedPath).toEqual(expect.any(String)); + const stagedDir = path.join(openclawHome, ".openclaw", "media", "qqbot", "host-read", "voice"); + const relativePath = path.relative(stagedDir, stagedPath as string); + expect(relativePath).not.toMatch(/^\.\.(?:[\\/]|$)/); + expect(path.isAbsolute(relativePath)).toBe(false); + await expect(fs.readFile(stagedPath as string)).resolves.toEqual(Buffer.from("audio bytes")); + await expect(fs.readdir(openclawHome)).resolves.not.toContain( + expect.stringMatching(/^escape-.*\.mp3$/), + ); + }); }); diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts index 658910a79ca0..237407aff1d1 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts @@ -3,7 +3,7 @@ */ import { randomUUID } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { extensionForMime, type MediaKind } from "openclaw/plugin-sdk/media-mime"; @@ -11,6 +11,8 @@ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; import { pathExistsSync, resolveLocalPathFromRootsSync, + sanitizeUntrustedFileName, + writeExternalFileWithinRoot, } from "openclaw/plugin-sdk/security-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; @@ -252,17 +254,21 @@ async function stageLoadedHostReadVoice( loaded: LoadedOutboundMedia, ): Promise { const stagedDir = getQQBotMediaDir("host-read", "voice"); - await mkdir(stagedDir, { recursive: true }); - const rawFileName = sanitizeFileName(loaded.fileName || path.basename(mediaPath) || "voice"); - const ext = path.extname(rawFileName); - const inferredExt = extensionForMime(loaded.contentType); - const baseName = sanitizeFileName(path.basename(rawFileName, ext)) || "voice"; - const stagedPath = path.join( - stagedDir, - `${baseName}-${randomUUID()}${ext || inferredExt || ".bin"}`, + // Decode QQ escapes once before applying portable basename policy. Decoding + // again after basename can recreate traversal separators at the write boundary. + const normalizedFileName = sanitizeFileName( + loaded.fileName || path.basename(mediaPath) || "voice", ); - await writeFile(stagedPath, loaded.buffer); - return stagedPath; + const safeFileName = sanitizeUntrustedFileName(normalizedFileName, "voice"); + const ext = path.extname(safeFileName); + const inferredExt = extensionForMime(loaded.contentType); + const baseName = path.basename(safeFileName, ext) || "voice"; + const staged = await writeExternalFileWithinRoot({ + rootDir: stagedDir, + path: `${baseName}-${randomUUID()}${ext || inferredExt || ".bin"}`, + write: async (tempPath) => await writeFile(tempPath, loaded.buffer), + }); + return staged.path; } async function stageHostReadVoice(