fix(qqbot): guard channel api fetches

This commit is contained in:
Vincent Koc
2026-06-19 16:21:45 +02:00
parent 78b5618071
commit 51ebe87a09
2 changed files with 169 additions and 35 deletions

View File

@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
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<Uint8Array>({
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);
});
});

View File

@@ -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<void>) | 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),