fix: the bundled qq bot extension extensions qqbot pe (#329) (#62082)

This commit is contained in:
Devin Robison
2026-04-06 13:50:33 -06:00
committed by GitHub
parent 7e3f345ee9
commit 37d7c716f4
3 changed files with 93 additions and 12 deletions

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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<string | null> {
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;