mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(qqbot): confine staged voice filenames (#120188)
This commit is contained in:
committed by
GitHub
parent
fc9905edb2
commit
e688ed6b88
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user