mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
@@ -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
|
||||
|
||||
|
||||
61
extensions/qqbot/src/utils/file-utils.test.ts
Normal file
61
extensions/qqbot/src/utils/file-utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user