diff --git a/extensions/qqbot/src/engine/tools/channel-api.test.ts b/extensions/qqbot/src/engine/tools/channel-api.test.ts new file mode 100644 index 000000000000..d6e0d259f4d6 --- /dev/null +++ b/extensions/qqbot/src/engine/tools/channel-api.test.ts @@ -0,0 +1,110 @@ +// Qqbot tests cover channel-api tool behavior. +import { afterEach, describe, expect, it, vi } from "vitest"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchWithSsrFGuardMock, + }; +}); + +import { executeChannelApi } from "./channel-api.js"; + +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, init), + wasCanceled: () => canceled, + }; +} + +describe("executeChannelApi", () => { + afterEach(() => { + vi.restoreAllMocks(); + fetchWithSsrFGuardMock.mockReset(); + }); + + it("uses guarded QQ API fetches and releases successful responses", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ id: "guild-1" }), { status: 200 }), + release, + }); + + const result = await executeChannelApi( + { method: "GET", path: "/users/@me/guilds", query: { limit: "1" } }, + { accessToken: "token-1" }, + ); + + expect(result.details).toEqual({ + success: true, + status: 200, + path: "/users/@me/guilds", + data: { id: "guild-1" }, + }); + expect(release).toHaveBeenCalledTimes(1); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ + url: "https://api.sgroup.qq.com/users/@me/guilds?limit=1", + init: { + method: "GET", + headers: { + Authorization: "QQBot token-1", + "Content-Type": "application/json", + }, + signal: expect.any(AbortSignal), + }, + auditContext: "qqbot-channel-api", + policy: { + hostnameAllowlist: ["api.sgroup.qq.com"], + allowRfc2544BenchmarkRange: true, + }, + }); + }); + + it("bounds error bodies without using response.text()", async () => { + const release = vi.fn(async () => {}); + const tracked = cancelTrackedResponse(`${"channel api unavailable ".repeat(1024)}tail`, { + status: 503, + statusText: "Service Unavailable", + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: tracked.response, + release, + }); + + const result = await executeChannelApi( + { method: "GET", path: "/guilds/123/channels" }, + { accessToken: "token-1" }, + ); + + expect(result.details).toMatchObject({ + error: "503 Service Unavailable", + status: 503, + path: "/guilds/123/channels", + }); + expect(JSON.stringify(result.details)).toContain("channel api unavailable"); + expect(JSON.stringify(result.details)).not.toContain("tail"); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/qqbot/src/engine/tools/channel-api.ts b/extensions/qqbot/src/engine/tools/channel-api.ts index 88ac4d06b469..4d0df88ed909 100644 --- a/extensions/qqbot/src/engine/tools/channel-api.ts +++ b/extensions/qqbot/src/engine/tools/channel-api.ts @@ -8,11 +8,21 @@ * validation, fetch, and structured response formatting. */ +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { formatErrorMessage } from "../utils/format.js"; import { debugLog, debugError } from "../utils/log.js"; const API_BASE = "https://api.sgroup.qq.com"; const DEFAULT_TIMEOUT_MS = 30000; +const CHANNEL_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024; + +function resolveChannelApiSsrfPolicy(url: string): SsrFPolicy { + return { + hostnameAllowlist: [new URL(url).hostname], + allowRfc2544BenchmarkRange: true, + }; +} /** * Channel API call parameters. @@ -174,8 +184,16 @@ export async function executeChannelApi( debugLog(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`); let res: Response; + let release: (() => Promise) | undefined; try { - res = await fetch(url, fetchOptions); + const guarded = await fetchWithSsrFGuard({ + url, + init: fetchOptions, + auditContext: "qqbot-channel-api", + policy: resolveChannelApiSsrfPolicy(url), + }); + res = guarded.response; + release = guarded.release; } catch (err) { clearTimeout(timeoutId); if (err instanceof Error && err.name === "AbortError") { @@ -194,47 +212,53 @@ export async function executeChannelApi( clearTimeout(timeoutId); } - debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`); - - const rawBody = await res.text(); - if (!rawBody || rawBody.trim() === "") { - if (res.ok) { - return json({ success: true, status: res.status, path: params.path }); - } - return json({ - error: `API returned ${res.status} ${res.statusText}`, - status: res.status, - path: params.path, - }); - } - - let parsed: unknown; try { - parsed = JSON.parse(rawBody); - } catch { - parsed = rawBody; - } + debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`); + + const rawBody = res.ok + ? await res.text() + : await readResponseTextLimited(res, CHANNEL_API_ERROR_BODY_LIMIT_BYTES); + if (!rawBody || rawBody.trim() === "") { + if (res.ok) { + return json({ success: true, status: res.status, path: params.path }); + } + return json({ + error: `API returned ${res.status} ${res.statusText}`, + status: res.status, + path: params.path, + }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + parsed = rawBody; + } + + if (!res.ok) { + const errMsg = + typeof parsed === "object" && parsed && "message" in parsed + ? String((parsed as { message?: unknown }).message) + : `${res.status} ${res.statusText}`; + debugError(`[qqbot-channel-api] Error [${method} ${params.path}]: ${errMsg}`); + return json({ + error: errMsg, + status: res.status, + path: params.path, + details: parsed, + }); + } - if (!res.ok) { - const errMsg = - typeof parsed === "object" && parsed && "message" in parsed - ? String((parsed as { message?: unknown }).message) - : `${res.status} ${res.statusText}`; - debugError(`[qqbot-channel-api] Error [${method} ${params.path}]: ${errMsg}`); return json({ - error: errMsg, + success: true, status: res.status, path: params.path, - details: parsed, + data: parsed, }); + } finally { + await release?.(); } - - return json({ - success: true, - status: res.status, - path: params.path, - data: parsed, - }); } catch (err) { return json({ error: formatErrorMessage(err),