From 37d7c716f4204ba7dafe2d8e98a443186d2fd697 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 6 Apr 2026 13:50:33 -0600 Subject: [PATCH] fix: the bundled qq bot extension extensions qqbot pe (#329) (#62082) --- CHANGELOG.md | 1 + extensions/qqbot/src/utils/file-utils.test.ts | 61 +++++++++++++++++++ extensions/qqbot/src/utils/file-utils.ts | 43 +++++++++---- 3 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 extensions/qqbot/src/utils/file-utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9091ed3225..9f0c1a811a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951) - Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc. - Agents/exec: keep `strictInlineEval` commands blocked after approval timeouts on both gateway and node exec hosts, so timeout fallback no longer turns timed-out inline interpreter prompts into automatic execution. +- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts. ## 2026.4.5 diff --git a/extensions/qqbot/src/utils/file-utils.test.ts b/extensions/qqbot/src/utils/file-utils.test.ts new file mode 100644 index 00000000000..3c0b64ca9f0 --- /dev/null +++ b/extensions/qqbot/src/utils/file-utils.test.ts @@ -0,0 +1,61 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mediaRuntimeMocks = vi.hoisted(() => ({ + fetchRemoteMedia: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ + fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args), +})); + +import { QQBOT_MEDIA_SSRF_POLICY, downloadFile } from "./file-utils.js"; + +describe("qqbot file-utils downloadFile", () => { + let tempDir: string; + + beforeEach(async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockReset(); + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "qqbot-file-utils-")); + }); + + afterEach(async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + it("downloads through the guarded media runtime with the qqbot SSRF policy", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("image-bytes"), + contentType: "image/png", + fileName: "remote.png", + }); + + const savedPath = await downloadFile( + "https://media.qq.com/assets/photo.png", + tempDir, + "photo.png", + ); + + expect(savedPath).toBeTruthy(); + expect(savedPath).toMatch(/photo_\d+_[0-9a-f]{6}\.png$/); + expect(await fs.promises.readFile(savedPath!, "utf8")).toBe("image-bytes"); + expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledWith({ + url: "https://media.qq.com/assets/photo.png", + filePathHint: "photo.png", + ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, + }); + expect(QQBOT_MEDIA_SSRF_POLICY).toEqual({ + hostnameAllowlist: ["*.myqcloud.com", "*.qpic.cn", "*.qq.com", "*.tencentcos.com"], + allowRfc2544BenchmarkRange: true, + }); + }); + + it("rejects non-HTTPS URLs before attempting a fetch", async () => { + const savedPath = await downloadFile("http://media.qq.com/assets/photo.png", tempDir); + + expect(savedPath).toBeNull(); + expect(mediaRuntimeMocks.fetchRemoteMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/qqbot/src/utils/file-utils.ts b/extensions/qqbot/src/utils/file-utils.ts index 53c63591a4d..bc71d141fdd 100644 --- a/extensions/qqbot/src/utils/file-utils.ts +++ b/extensions/qqbot/src/utils/file-utils.ts @@ -1,6 +1,8 @@ import crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; +import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; +import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; /** Maximum file size accepted by the QQ Bot API. */ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; @@ -8,6 +10,18 @@ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; /** Threshold used to treat an upload as a large file. */ export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; +const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [ + "*.myqcloud.com", + "*.qpic.cn", + "*.qq.com", + "*.tencentcos.com", +]; + +export const QQBOT_MEDIA_SSRF_POLICY: SsrFPolicy = { + hostnameAllowlist: QQBOT_MEDIA_HOSTNAME_ALLOWLIST, + allowRfc2544BenchmarkRange: true, +}; + /** Result of local file-size validation. */ export interface FileSizeCheckResult { ok: boolean; @@ -110,23 +124,29 @@ export async function downloadFile( originalFilename?: string, ): Promise { try { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return null; + } + if (parsedUrl.protocol !== "https:") { + return null; + } + if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } - const resp = await fetch(url, { redirect: "follow" }); - if (!resp.ok || !resp.body) { - return null; - } + const fetched = await fetchRemoteMedia({ + url: parsedUrl.toString(), + filePathHint: originalFilename, + ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, + }); let filename = originalFilename?.trim() || ""; if (!filename) { - try { - const urlPath = new URL(url).pathname; - filename = path.basename(urlPath) || "download"; - } catch { - filename = "download"; - } + filename = fetched.fileName?.trim() || path.basename(parsedUrl.pathname) || "download"; } const ts = Date.now(); @@ -136,8 +156,7 @@ export async function downloadFile( const safeFilename = `${base}_${ts}_${rand}${ext}`; const destPath = path.join(destDir, safeFilename); - const buffer = Buffer.from(await resp.arrayBuffer()); - await fs.promises.writeFile(destPath, buffer); + await fs.promises.writeFile(destPath, fetched.buffer); return destPath; } catch { return null;