diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bea4d7c192..a454525fcdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(qqbot): add SSRF guard to direct-upload URL paths in uploadC2CMedia and uploadGroupMedia [AI-assisted]. (#69595) Thanks @pgondhi987. - fix(gateway): enforce allowRequestSessionKey gate on template-rendered mapping sessionKeys. (#69381) Thanks @pgondhi987. - Webchat/images: treat inline image attachments as media for empty-turn gating while still ignoring metadata-only blank turns. (#69474) Thanks @Jaswir. - OpenAI/Responses: resolve `/think` levels against each GPT model's supported reasoning efforts so `/think off` no longer becomes high reasoning or sends unsupported `reasoning.effort: "none"` payloads. diff --git a/extensions/qqbot/src/api.security.test.ts b/extensions/qqbot/src/api.security.test.ts new file mode 100644 index 00000000000..78dcc58110c --- /dev/null +++ b/extensions/qqbot/src/api.security.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ssrfMocks = vi.hoisted(() => ({ + fetchWithSsrFGuard: vi.fn(), + resolvePinnedHostnameWithPolicy: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard, + resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy, +})); + +vi.mock("./utils/debug-log.js", () => ({ + debugError: vi.fn(), + debugLog: vi.fn(), +})); + +import { MediaFileType, uploadC2CMedia, uploadGroupMedia } from "./api.js"; +import { clearUploadCache, computeFileHash, setCachedFileInfo } from "./utils/upload-cache.js"; + +describe("qqbot direct upload SSRF guard", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearUploadCache(); + ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({ + hostname: "example.com", + addresses: ["203.0.113.10"], + lookup: vi.fn(), + }); + ssrfMocks.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response(JSON.stringify({ file_uuid: "uuid", file_info: "info", ttl: 3600 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + release: async () => {}, + }); + }); + + it("blocks direct-upload URLs that target private or internal hosts", async () => { + ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce( + new Error("Blocked hostname or private/internal/special-use IP address"), + ); + + await expect( + uploadC2CMedia( + "access-token", + "user-1", + MediaFileType.IMAGE, + "https://169.254.169.254/latest/meta-data/iam/security-credentials/", + ), + ).rejects.toThrow("Blocked hostname or private/internal/special-use IP address"); + + expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); + }); + + it("blocks non-HTTPS direct-upload URLs before the QQ upload request", async () => { + await expect( + uploadGroupMedia( + "access-token", + "group-1", + MediaFileType.FILE, + "http://cdn.qpic.cn/payload.txt", + ), + ).rejects.toThrow("Direct-upload media URL must use HTTPS"); + + expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); + expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); + }); + + it("allows public HTTPS direct-upload URLs", async () => { + const result = await uploadC2CMedia( + "access-token", + "user-1", + MediaFileType.IMAGE, + "https://example.com/payload.png", + ); + + expect(result).toEqual({ file_uuid: "uuid", file_info: "info", ttl: 3600 }); + expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com"); + expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1); + }); + + it("allows public HTTPS direct-upload URLs for group uploads", async () => { + const result = await uploadGroupMedia( + "access-token", + "group-1", + MediaFileType.FILE, + "https://example.com/payload.txt", + ); + + expect(result).toEqual({ file_uuid: "uuid", file_info: "info", ttl: 3600 }); + expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com"); + expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1); + }); + + it("skips URL validation on c2c cache hits when fileData is reused", async () => { + const fileData = "cached-file-data"; + setCachedFileInfo( + computeFileHash(fileData), + "c2c", + "user-1", + MediaFileType.IMAGE, + "cached-info", + "cached-uuid", + 3600, + ); + + const result = await uploadC2CMedia( + "access-token", + "user-1", + MediaFileType.IMAGE, + "https://example.com/stale.png", + fileData, + ); + + expect(result).toEqual({ file_uuid: "", file_info: "cached-info", ttl: 0 }); + expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); + expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); + }); + + it("skips URL validation on group cache hits when fileData is reused", async () => { + const fileData = "cached-group-file-data"; + setCachedFileInfo( + computeFileHash(fileData), + "group", + "group-1", + MediaFileType.FILE, + "cached-group-info", + "cached-group-uuid", + 3600, + ); + + const result = await uploadGroupMedia( + "access-token", + "group-1", + MediaFileType.FILE, + "https://example.com/stale.txt", + fileData, + ); + + expect(result).toEqual({ file_uuid: "", file_info: "cached-group-info", ttl: 0 }); + expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); + expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/qqbot/src/api.ts b/extensions/qqbot/src/api.ts index 3b7b7160db9..1bbe1e62eb6 100644 --- a/extensions/qqbot/src/api.ts +++ b/extensions/qqbot/src/api.ts @@ -2,7 +2,10 @@ import { createRequire } from "node:module"; import os from "node:os"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + fetchWithSsrFGuard, + resolvePinnedHostnameWithPolicy, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { debugLog, debugError } from "./utils/debug-log.js"; import { sanitizeFileName } from "./utils/platform.js"; @@ -534,6 +537,22 @@ export interface UploadMediaResponse { id?: string; } +async function assertDirectUploadUrlAllowed(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch (err) { + throw new Error(`Invalid media URL: ${formatErrorMessage(err)}`, { cause: err }); + } + + if (parsed.protocol !== "https:") { + throw new Error("Direct-upload media URL must use HTTPS"); + } + + await resolvePinnedHostnameWithPolicy(parsed.hostname); + return parsed.toString(); +} + export async function uploadC2CMedia( accessToken: string, openid: string, @@ -557,7 +576,7 @@ export async function uploadC2CMedia( const body: Record = { file_type: fileType, srv_send_msg: srvSendMsg }; if (url) { - body.url = url; + body.url = await assertDirectUploadUrlAllowed(url); } else if (fileData) { body.file_data = fileData; } @@ -610,7 +629,7 @@ export async function uploadGroupMedia( const body: Record = { file_type: fileType, srv_send_msg: srvSendMsg }; if (url) { - body.url = url; + body.url = await assertDirectUploadUrlAllowed(url); } else if (fileData) { body.file_data = fileData; }