fix(qqbot): confine staged voice filenames (#120188)

This commit is contained in:
Pavan Kumar Gondhi
2026-08-09 20:18:31 +05:30
committed by GitHub
parent fc9905edb2
commit e688ed6b88
2 changed files with 44 additions and 11 deletions
@@ -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$/),
);
});
});
@@ -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<string> {
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(