High severity8.6GHSA Advisory· Published May 5, 2026· Updated May 7, 2026
CVE-2026-43533
CVE-2026-43533
Description
OpenClaw before 2026.4.10 contains an arbitrary file read vulnerability in QQBot media tags that allows attackers to reference host-local paths outside the intended media storage boundary. Attackers can craft malicious reply text containing media tags to disclose arbitrary local files through outbound media handling.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.10 | 2026.4.10 |
Affected products
2Patches
1604777e4414cfix(qqbot): enforce media storage boundary for all outbound local file paths [AI] (#63271)
4 files changed · +612 −16
CHANGELOG.md+1 −0 modified@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(qqbot): enforce media storage boundary for all outbound local file paths [AI]. (#63271) Thanks @pgondhi987. - iMessage/self-chat: distinguish normal DM outbound rows from true self-chat using `destination_caller_id` plus chat participants, while preserving multi-handle self-chat aliases so outbound DM replies stop looping back as inbound messages. (#61619) Thanks @neeravmakwana. - fix(browser): auto-generate browser control auth token for none/trusted-proxy modes [AI]. (#63280) Thanks @pgondhi987. - fix(exec): replace TOCTOU check-then-read with atomic pinned-fd open in script preflight [AI]. (#62333) Thanks @pgondhi987.
extensions/qqbot/index.ts+17 −4 modified@@ -17,6 +17,9 @@ type MediaTargetContext = { account: QQBotAccount; logPrefix: string; }; +type SendDocumentOptions = { + allowQQBotDataDownloads?: boolean; +}; type QQBotFrameworkCommandResult = | string @@ -44,14 +47,22 @@ function resolveQQBotAccount(config: unknown, accountId?: string): QQBotAccount return resolve(config, accountId); } -function sendDocument(context: MediaTargetContext, filePath: string) { +function sendDocument( + context: MediaTargetContext, + filePath: string, + options?: SendDocumentOptions, +) { const send = loadBundledEntryExportSync< - (context: MediaTargetContext, filePath: string) => Promise<unknown> + ( + context: MediaTargetContext, + filePath: string, + options?: SendDocumentOptions, + ) => Promise<unknown> >(import.meta.url, { specifier: "./api.js", exportName: "sendDocument", }); - return send(context, filePath); + return send(context, filePath, options); } function getFrameworkCommands(): QQBotFrameworkCommand[] { @@ -173,7 +184,9 @@ export default defineBundledChannelEntry({ account, logPrefix: `[qqbot:${account.accountId}]`, }; - await sendDocument(mediaCtx, String(result.filePath)); + await sendDocument(mediaCtx, String(result.filePath), { + allowQQBotDataDownloads: true, + }); } catch { // File send failed; the text summary is still returned below. }
extensions/qqbot/src/outbound.security.test.ts+401 −0 added@@ -0,0 +1,401 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedQQBotAccount } from "./types.js"; +import { getQQBotDataDir, getQQBotMediaDir } from "./utils/platform.js"; + +const apiMocks = vi.hoisted(() => ({ + getAccessToken: vi.fn(async () => "token"), + sendC2CFileMessage: vi.fn(async () => ({ id: "msg-c2c-file", timestamp: "ts" })), + sendC2CImageMessage: vi.fn(async () => ({ id: "msg-c2c-image", timestamp: "ts" })), + sendC2CMessage: vi.fn(async () => ({ id: "msg-c2c-text", timestamp: "ts" })), + sendC2CVideoMessage: vi.fn(async () => ({ id: "msg-c2c-video", timestamp: "ts" })), + sendC2CVoiceMessage: vi.fn(async () => ({ id: "msg-c2c-voice", timestamp: "ts" })), + sendChannelMessage: vi.fn(async () => ({ id: "msg-channel", timestamp: "ts" })), + sendDmMessage: vi.fn(async () => ({ id: "msg-dm", timestamp: "ts" })), + sendGroupFileMessage: vi.fn(async () => ({ id: "msg-group-file", timestamp: "ts" })), + sendGroupImageMessage: vi.fn(async () => ({ id: "msg-group-image", timestamp: "ts" })), + sendGroupMessage: vi.fn(async () => ({ id: "msg-group-text", timestamp: "ts" })), + sendGroupVideoMessage: vi.fn(async () => ({ id: "msg-group-video", timestamp: "ts" })), + sendGroupVoiceMessage: vi.fn(async () => ({ id: "msg-group-voice", timestamp: "ts" })), + sendProactiveC2CMessage: vi.fn(async () => ({ id: "msg-proactive-c2c", timestamp: "ts" })), + sendProactiveGroupMessage: vi.fn(async () => ({ id: "msg-proactive-group", timestamp: "ts" })), +})); + +const audioConvertMocks = vi.hoisted(() => ({ + audioFileToSilkBase64: vi.fn(async () => "c2lsaw=="), + isAudioFile: vi.fn((filePath: string, mimeType?: string) => { + if (mimeType === "voice" || mimeType?.startsWith("audio/")) { + return true; + } + return ( + filePath.endsWith(".mp3") || + filePath.endsWith(".wav") || + filePath.endsWith(".amr") || + filePath.endsWith(".ogg") + ); + }), + shouldTranscodeVoice: vi.fn(() => false), + waitForFile: vi.fn(async (_filePath: string) => 1024), +})); + +const fileUtilsMocks = vi.hoisted(() => ({ + checkFileSize: vi.fn(() => ({ ok: true })), + downloadFile: vi.fn(), + fileExistsAsync: vi.fn(async () => true), + formatFileSize: vi.fn((size: number) => `${size}`), + readFileAsync: vi.fn(async () => Buffer.from("file-data")), +})); + +vi.mock("./api.js", () => apiMocks); + +vi.mock("./utils/audio-convert.js", () => ({ + audioFileToSilkBase64: audioConvertMocks.audioFileToSilkBase64, + isAudioFile: audioConvertMocks.isAudioFile, + shouldTranscodeVoice: audioConvertMocks.shouldTranscodeVoice, + waitForFile: audioConvertMocks.waitForFile, +})); + +vi.mock("./utils/file-utils.js", () => ({ + checkFileSize: fileUtilsMocks.checkFileSize, + downloadFile: fileUtilsMocks.downloadFile, + fileExistsAsync: fileUtilsMocks.fileExistsAsync, + formatFileSize: fileUtilsMocks.formatFileSize, + readFileAsync: fileUtilsMocks.readFileAsync, +})); + +vi.mock("./utils/debug-log.js", () => ({ + debugError: vi.fn(), + debugLog: vi.fn(), + debugWarn: vi.fn(), +})); + +import { + sendDocument, + sendMedia, + sendPhoto, + sendVideoMsg, + sendVoice, + type MediaOutboundContext, + type MediaTargetContext, + type OutboundResult, +} from "./outbound.js"; + +const createdRoots: string[] = []; + +const account: ResolvedQQBotAccount = { + accountId: "default", + enabled: true, + appId: "app-id", + clientSecret: "secret", + secretSource: "config", + markdownSupport: true, + config: {}, +}; + +function buildTarget(): MediaTargetContext { + return { + targetType: "c2c", + targetId: "user-1", + account, + replyToId: "msg-1", + logPrefix: "[qqbot:test]", + }; +} + +function buildMediaContext(mediaUrl: string): MediaOutboundContext { + return { + to: "qqbot:c2c:user-1", + text: "", + account, + mediaUrl, + replyToId: "msg-1", + }; +} + +function createOutsideFile(ext: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-security-")); + createdRoots.push(root); + const filePath = path.join(root, `payload${ext}`); + fs.writeFileSync(filePath, "payload", "utf8"); + return filePath; +} + +function createAllowedCommandDownloadPath(ext: string): string { + const root = fs.mkdtempSync(path.join(getQQBotDataDir("downloads"), "command-download-")); + createdRoots.push(root); + const filePath = path.join(root, `download${ext}`); + fs.writeFileSync(filePath, "payload", "utf8"); + return filePath; +} + +function createAllowedMediaPath( + ext: string, + options: { createFile?: boolean; content?: string } = {}, +): string { + const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-security-")); + createdRoots.push(root); + const filePath = path.join(root, `allowed${ext}`); + if (options.createFile !== false) { + fs.writeFileSync(filePath, options.content ?? "payload", "utf8"); + } + return filePath; +} + +function createDelayedMissingMediaPath(ext: string): string { + const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-delayed-security-")); + createdRoots.push(root); + return path.join(root, "pending", `delayed${ext}`); +} + +function createMissingSymlinkEscapePath(ext: string): string | null { + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-symlink-outside-")); + createdRoots.push(outsideRoot); + + const inMediaRoot = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-symlink-")); + createdRoots.push(inMediaRoot); + + const linkPath = path.join(inMediaRoot, "link"); + try { + fs.symlinkSync(outsideRoot, linkPath, "dir"); + } catch { + return null; + } + + return path.join(linkPath, `delayed${ext}`); +} + +function writeFileWithParents(filePath: string, content: string = "payload"): number { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf8"); + return fs.statSync(filePath).size; +} + +function expectBlocked(result: OutboundResult, expectedError: string): void { + expect(result.channel).toBe("qqbot"); + expect(result.error).toBe(expectedError); + expect(apiMocks.getAccessToken).not.toHaveBeenCalled(); +} + +const nonDotRelativeTraversalPath = "src/../../../../etc/passwd"; + +afterEach(() => { + vi.clearAllMocks(); + for (const root of createdRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("qqbot outbound local media path security", () => { + it("allows local image paths inside QQ Bot media storage", async () => { + const allowedPath = createAllowedMediaPath(".png"); + const result = await sendPhoto(buildTarget(), allowedPath); + + expect(result.error).toBeUndefined(); + expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1); + expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledTimes(1); + }); + + it("blocks local image paths outside QQ Bot media storage", async () => { + const outsidePath = createOutsideFile(".png"); + const result = await sendPhoto(buildTarget(), outsidePath); + + expectBlocked(result, "Image path must be inside QQ Bot media storage"); + }); + + it("blocks local voice paths outside QQ Bot media storage", async () => { + const outsidePath = createOutsideFile(".mp3"); + const result = await sendVoice(buildTarget(), outsidePath, undefined, false); + + expectBlocked(result, "Voice path must be inside QQ Bot media storage"); + }); + + it("allows delayed local voice paths inside QQ Bot media storage", async () => { + const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false }); + audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => + writeFileWithParents(candidatePath), + ); + const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true); + + expect(result.error).toBeUndefined(); + expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1); + expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1); + }); + + it("blocks delayed voice paths when a missing segment is replaced by a symlink after precheck", async () => { + const delayedVoicePath = createDelayedMissingMediaPath(".mp3"); + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-race-outside-")); + createdRoots.push(outsideRoot); + + const symlinkProbe = path.join(path.dirname(path.dirname(delayedVoicePath)), "probe-link"); + try { + fs.symlinkSync(outsideRoot, symlinkProbe, "dir"); + fs.unlinkSync(symlinkProbe); + } catch { + return; + } + + audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => { + const symlinkParent = path.dirname(candidatePath); + fs.symlinkSync(outsideRoot, symlinkParent, "dir"); + const outsideFile = path.join(outsideRoot, path.basename(candidatePath)); + return writeFileWithParents(outsideFile); + }); + + const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true); + + expectBlocked(result, "Voice path must be inside QQ Bot media storage"); + }); + + it("returns a blocked result when missing-path canonicalization cannot resolve root", async () => { + const originalExistsSync = fs.existsSync.bind(fs); + const originalRealpathSync = fs.realpathSync.bind(fs); + + const existsSpy = vi.spyOn(fs, "existsSync"); + existsSpy.mockImplementation((candidate: fs.PathLike) => { + const candidateText = typeof candidate === "string" ? candidate : candidate.toString(); + const root = path.parse(candidateText).root; + if (candidateText === root) { + return false; + } + return originalExistsSync(candidate); + }); + + const realpathSpy = vi.spyOn(fs, "realpathSync"); + realpathSpy.mockImplementation(((candidate: fs.PathLike) => { + const candidateText = typeof candidate === "string" ? candidate : candidate.toString(); + const root = path.parse(candidateText).root; + if (candidateText === root) { + throw new Error("missing-root"); + } + return originalRealpathSync(candidate); + }) as typeof fs.realpathSync); + + try { + const result = await sendVoice( + buildTarget(), + "/qqbot-missing-root/sub/path.mp3", + undefined, + true, + ); + expectBlocked(result, "Voice path must be inside QQ Bot media storage"); + } finally { + existsSpy.mockRestore(); + realpathSpy.mockRestore(); + } + }); + + it("blocks delayed voice paths that escape via symlinked parent directories", async () => { + const delayedVoicePath = createMissingSymlinkEscapePath(".mp3"); + if (!delayedVoicePath) { + return; + } + + const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true); + + expectBlocked(result, "Voice path must be inside QQ Bot media storage"); + }); + + it("blocks local video paths outside QQ Bot media storage", async () => { + const outsidePath = createOutsideFile(".mp4"); + const result = await sendVideoMsg(buildTarget(), outsidePath); + + expectBlocked(result, "Video path must be inside QQ Bot media storage"); + }); + + it("blocks local document paths outside QQ Bot media storage", async () => { + const outsidePath = createOutsideFile(".txt"); + const result = await sendDocument(buildTarget(), outsidePath); + + expectBlocked(result, "File path must be inside QQ Bot media storage"); + }); + + it("blocks QQ Bot command-download paths for sendDocument by default", async () => { + const commandDownloadPath = createAllowedCommandDownloadPath(".txt"); + const result = await sendDocument(buildTarget(), commandDownloadPath); + + expectBlocked(result, "File path must be inside QQ Bot media storage"); + }); + + it("allows QQ Bot command-download paths for sendDocument when explicitly enabled", async () => { + const commandDownloadPath = createAllowedCommandDownloadPath(".txt"); + const result = await sendDocument(buildTarget(), commandDownloadPath, { + allowQQBotDataDownloads: true, + }); + + expect(result.error).toBeUndefined(); + expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(2); + expect(apiMocks.sendC2CFileMessage).toHaveBeenCalledTimes(1); + }); + + it("blocks non-dot relative traversal paths for document sends", async () => { + const result = await sendDocument(buildTarget(), nonDotRelativeTraversalPath); + + expectBlocked(result, "File path must be inside QQ Bot media storage"); + }); + + it("blocks sendMedia local paths outside QQ Bot media storage", async () => { + const outsidePath = createOutsideFile(".txt"); + const result = await sendMedia(buildMediaContext(outsidePath)); + + expectBlocked(result, "Media path must be inside QQ Bot media storage"); + }); + + it("allows delayed local audio paths in sendMedia inside QQ Bot media storage", async () => { + const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false }); + audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => + writeFileWithParents(candidatePath), + ); + const result = await sendMedia(buildMediaContext(delayedVoicePath)); + + expect(result.error).toBeUndefined(); + expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1); + expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1); + }); + + it("blocks sendMedia delayed audio paths when a missing segment is replaced by a symlink", async () => { + const delayedVoicePath = createDelayedMissingMediaPath(".mp3"); + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-race-sendmedia-")); + createdRoots.push(outsideRoot); + + const symlinkProbe = path.join(path.dirname(path.dirname(delayedVoicePath)), "probe-link"); + try { + fs.symlinkSync(outsideRoot, symlinkProbe, "dir"); + fs.unlinkSync(symlinkProbe); + } catch { + return; + } + + audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => { + const symlinkParent = path.dirname(candidatePath); + fs.symlinkSync(outsideRoot, symlinkParent, "dir"); + const outsideFile = path.join(outsideRoot, path.basename(candidatePath)); + return writeFileWithParents(outsideFile); + }); + + const result = await sendMedia(buildMediaContext(delayedVoicePath)); + + expectBlocked( + result, + "voice: Voice path must be inside QQ Bot media storage | fallback file: File path must be inside QQ Bot media storage", + ); + }); + + it("blocks sendMedia delayed audio paths that escape via symlinked parents", async () => { + const delayedVoicePath = createMissingSymlinkEscapePath(".mp3"); + if (!delayedVoicePath) { + return; + } + + const result = await sendMedia(buildMediaContext(delayedVoicePath)); + + expectBlocked(result, "Media path must be inside QQ Bot media storage"); + }); + + it("blocks non-dot relative traversal paths in sendMedia", async () => { + const result = await sendMedia(buildMediaContext(nonDotRelativeTraversalPath)); + + expectBlocked(result, "Media path must be inside QQ Bot media storage"); + }); +});
extensions/qqbot/src/outbound.ts+193 −12 modified@@ -1,3 +1,4 @@ +import * as fs from "node:fs"; import * as path from "path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { @@ -39,10 +40,11 @@ import { import { normalizeMediaTags } from "./utils/media-tags.js"; import { decodeCronPayload } from "./utils/payload.js"; import { + getQQBotDataDir, getQQBotMediaDir, isLocalPath as isLocalFilePath, normalizePath, - resolveQQBotLocalMediaPath, + resolveQQBotPayloadLocalFilePath, sanitizeFileName, } from "./utils/platform.js"; @@ -273,6 +275,148 @@ function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean { return account.config?.urlDirectUpload !== false; } +type QQBotMediaKind = "image" | "voice" | "video" | "file" | "media"; + +const qqBotMediaKindLabel: Record<QQBotMediaKind, string> = { + image: "Image", + voice: "Voice", + video: "Video", + file: "File", + media: "Media", +}; + +type ResolvedOutboundMediaPath = { ok: true; mediaPath: string } | { ok: false; error: string }; +type ResolveOutboundMediaPathOptions = { + allowMissingLocalPath?: boolean; + extraLocalRoots?: string[]; +}; +type SendDocumentOptions = { + allowQQBotDataDownloads?: boolean; +}; + +function isHttpOrDataSource(pathValue: string): boolean { + return ( + pathValue.startsWith("http://") || + pathValue.startsWith("https://") || + pathValue.startsWith("data:") + ); +} + +function isPathWithinRoot(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function resolveMissingPathWithinMediaRoot(normalizedPath: string): string | null { + const resolvedCandidate = path.resolve(normalizedPath); + if (fs.existsSync(resolvedCandidate)) { + return null; + } + + const allowedRoot = path.resolve(getQQBotMediaDir()); + let canonicalAllowedRoot: string; + try { + canonicalAllowedRoot = fs.realpathSync(allowedRoot); + } catch { + return null; + } + + const missingSegments: string[] = []; + let cursor = resolvedCandidate; + while (!fs.existsSync(cursor)) { + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + missingSegments.unshift(path.basename(cursor)); + cursor = parent; + } + + if (!fs.existsSync(cursor)) { + return null; + } + + let canonicalCursor: string; + try { + canonicalCursor = fs.realpathSync(cursor); + } catch { + return null; + } + const canonicalCandidate = + missingSegments.length > 0 ? path.join(canonicalCursor, ...missingSegments) : canonicalCursor; + + return isPathWithinRoot(canonicalCandidate, canonicalAllowedRoot) ? canonicalCandidate : null; +} + +function resolveExistingPathWithinRoots( + normalizedPath: string, + allowedRoots: readonly string[], +): string | null { + const resolvedCandidate = path.resolve(normalizedPath); + if (!fs.existsSync(resolvedCandidate)) { + return null; + } + + let canonicalCandidate: string; + try { + canonicalCandidate = fs.realpathSync(resolvedCandidate); + } catch { + return null; + } + + for (const root of allowedRoots) { + const resolvedRoot = path.resolve(root); + const canonicalRoot = fs.existsSync(resolvedRoot) + ? fs.realpathSync(resolvedRoot) + : resolvedRoot; + if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) { + return canonicalCandidate; + } + } + + return null; +} + +function resolveOutboundMediaPath( + rawPath: string, + prefix: string, + mediaKind: QQBotMediaKind, + options: ResolveOutboundMediaPathOptions = {}, +): ResolvedOutboundMediaPath { + const normalizedPath = normalizePath(rawPath); + if (isHttpOrDataSource(normalizedPath)) { + return { ok: true, mediaPath: normalizedPath }; + } + + const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath); + if (allowedPath) { + return { ok: true, mediaPath: allowedPath }; + } + + if (options.extraLocalRoots && options.extraLocalRoots.length > 0) { + const extraAllowedPath = resolveExistingPathWithinRoots( + normalizedPath, + options.extraLocalRoots, + ); + if (extraAllowedPath) { + return { ok: true, mediaPath: extraAllowedPath }; + } + } + + if (options.allowMissingLocalPath) { + const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath); + if (allowedMissingPath) { + return { ok: true, mediaPath: allowedMissingPath }; + } + } + + debugWarn(`${prefix} blocked local ${mediaKind} path outside QQ Bot media storage`); + return { + ok: false, + error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`, + }; +} + /** * Send a photo from a local file, public URL, or Base64 data URL. */ @@ -281,7 +425,11 @@ export async function sendPhoto( imagePath: string, ): Promise<OutboundResult> { const prefix = ctx.logPrefix ?? "[qqbot]"; - const mediaPath = resolveQQBotLocalMediaPath(normalizePath(imagePath)); + const resolvedMediaPath = resolveOutboundMediaPath(imagePath, prefix, "image"); + if (!resolvedMediaPath.ok) { + return { channel: "qqbot", error: resolvedMediaPath.error }; + } + const mediaPath = resolvedMediaPath.mediaPath; const isLocal = isLocalFilePath(mediaPath); const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); const isData = mediaPath.startsWith("data:"); @@ -412,7 +560,13 @@ export async function sendVoice( transcodeEnabled: boolean = true, ): Promise<OutboundResult> { const prefix = ctx.logPrefix ?? "[qqbot]"; - const mediaPath = resolveQQBotLocalMediaPath(normalizePath(voicePath)); + const resolvedMediaPath = resolveOutboundMediaPath(voicePath, prefix, "voice", { + allowMissingLocalPath: true, + }); + if (!resolvedMediaPath.ok) { + return { channel: "qqbot", error: resolvedMediaPath.error }; + } + const mediaPath = resolvedMediaPath.mediaPath; const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); if (isHttp) { @@ -483,10 +637,17 @@ async function sendVoiceFromLocal( return { channel: "qqbot", error: "Voice generate failed" }; } - const needsTranscode = shouldTranscodeVoice(mediaPath); + // Re-check containment after the file appears to prevent symlink-race escapes. + const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath); + if (!safeMediaPath) { + debugWarn(`${prefix} sendVoice: blocked local voice path outside QQ Bot media storage`); + return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" }; + } + + const needsTranscode = shouldTranscodeVoice(safeMediaPath); if (needsTranscode && !transcodeEnabled) { - const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath)); + const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath)); debugLog( `${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`, ); @@ -497,11 +658,11 @@ async function sendVoiceFromLocal( } try { - const silkBase64 = await audioFileToSilkBase64(mediaPath, directUploadFormats); + const silkBase64 = await audioFileToSilkBase64(safeMediaPath, directUploadFormats); let uploadBase64 = silkBase64; if (!uploadBase64) { - const buf = await readFileAsync(mediaPath); + const buf = await readFileAsync(safeMediaPath); uploadBase64 = buf.toString("base64"); debugLog( `${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`, @@ -521,7 +682,7 @@ async function sendVoiceFromLocal( undefined, ctx.replyToId, undefined, - mediaPath, + safeMediaPath, ); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else if (ctx.targetType === "group") { @@ -551,7 +712,11 @@ export async function sendVideoMsg( videoPath: string, ): Promise<OutboundResult> { const prefix = ctx.logPrefix ?? "[qqbot]"; - const mediaPath = resolveQQBotLocalMediaPath(normalizePath(videoPath)); + const resolvedMediaPath = resolveOutboundMediaPath(videoPath, prefix, "video"); + if (!resolvedMediaPath.ok) { + return { channel: "qqbot", error: resolvedMediaPath.error }; + } + const mediaPath = resolvedMediaPath.mediaPath; const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); if (isHttp && !shouldDirectUploadUrl(ctx.account)) { @@ -670,9 +835,19 @@ async function sendVideoFromLocal( export async function sendDocument( ctx: MediaTargetContext, filePath: string, + options: SendDocumentOptions = {}, ): Promise<OutboundResult> { const prefix = ctx.logPrefix ?? "[qqbot]"; - const mediaPath = resolveQQBotLocalMediaPath(normalizePath(filePath)); + const extraLocalRoots = options.allowQQBotDataDownloads + ? [getQQBotDataDir("downloads")] + : undefined; + const resolvedMediaPath = resolveOutboundMediaPath(filePath, prefix, "file", { + extraLocalRoots, + }); + if (!resolvedMediaPath.ok) { + return { channel: "qqbot", error: resolvedMediaPath.error }; + } + const mediaPath = resolvedMediaPath.mediaPath; const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); const fileName = sanitizeFileName(path.basename(mediaPath)); @@ -1282,14 +1457,20 @@ export async function sendProactiveMessage( /** Send rich media, auto-routing by media type and source. */ export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> { const { to, text, replyToId, account, mimeType } = ctx; - const mediaUrl = resolveQQBotLocalMediaPath(normalizePath(ctx.mediaUrl)); if (!account.appId || !account.clientSecret) { return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; } - if (!mediaUrl) { + if (!ctx.mediaUrl) { return { channel: "qqbot", error: "mediaUrl is required for sendMedia" }; } + const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "[qqbot:sendMedia]", "media", { + allowMissingLocalPath: true, + }); + if (!resolvedMediaPath.ok) { + return { channel: "qqbot", error: resolvedMediaPath.error }; + } + const mediaUrl = resolvedMediaPath.mediaPath; const target = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendMedia]");
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/604777e4414cc3b2ff8861f18f4fb04374c702c6nvdPatchWEB
- github.com/advisories/GHSA-66r7-m7xm-v49hghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-66r7-m7xm-v49hnvdVendor AdvisoryWEB
- www.vulncheck.com/advisories/openclaw-arbitrary-local-file-read-via-qqbot-media-tagsnvdThird Party Advisory
- github.com/openclaw/openclaw/pull/63271ghsaWEB
News mentions
0No linked articles in our index yet.