Skip to content

Commit 51ebe87

Browse files
committed
fix(qqbot): guard channel api fetches
1 parent 78b5618 commit 51ebe87

2 files changed

Lines changed: 167 additions & 33 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Qqbot tests cover channel-api tool behavior.
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
5+
6+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
7+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
8+
return {
9+
...actual,
10+
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
11+
};
12+
});
13+
14+
import { executeChannelApi } from "./channel-api.js";
15+
16+
function cancelTrackedResponse(
17+
text: string,
18+
init: ResponseInit,
19+
): {
20+
response: Response;
21+
wasCanceled: () => boolean;
22+
} {
23+
let canceled = false;
24+
const stream = new ReadableStream<Uint8Array>({
25+
start(controller) {
26+
controller.enqueue(new TextEncoder().encode(text));
27+
},
28+
cancel() {
29+
canceled = true;
30+
},
31+
});
32+
return {
33+
response: new Response(stream, init),
34+
wasCanceled: () => canceled,
35+
};
36+
}
37+
38+
describe("executeChannelApi", () => {
39+
afterEach(() => {
40+
vi.restoreAllMocks();
41+
fetchWithSsrFGuardMock.mockReset();
42+
});
43+
44+
it("uses guarded QQ API fetches and releases successful responses", async () => {
45+
const release = vi.fn(async () => {});
46+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
47+
response: new Response(JSON.stringify({ id: "guild-1" }), { status: 200 }),
48+
release,
49+
});
50+
51+
const result = await executeChannelApi(
52+
{ method: "GET", path: "/users/@me/guilds", query: { limit: "1" } },
53+
{ accessToken: "token-1" },
54+
);
55+
56+
expect(result.details).toEqual({
57+
success: true,
58+
status: 200,
59+
path: "/users/@me/guilds",
60+
data: { id: "guild-1" },
61+
});
62+
expect(release).toHaveBeenCalledTimes(1);
63+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
64+
url: "https://api.sgroup.qq.com/users/@me/guilds?limit=1",
65+
init: {
66+
method: "GET",
67+
headers: {
68+
Authorization: "QQBot token-1",
69+
"Content-Type": "application/json",
70+
},
71+
signal: expect.any(AbortSignal),
72+
},
73+
auditContext: "qqbot-channel-api",
74+
policy: {
75+
hostnameAllowlist: ["api.sgroup.qq.com"],
76+
allowRfc2544BenchmarkRange: true,
77+
},
78+
});
79+
});
80+
81+
it("bounds error bodies without using response.text()", async () => {
82+
const release = vi.fn(async () => {});
83+
const tracked = cancelTrackedResponse(`${"channel api unavailable ".repeat(1024)}tail`, {
84+
status: 503,
85+
statusText: "Service Unavailable",
86+
headers: { "content-type": "text/plain" },
87+
});
88+
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
89+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
90+
response: tracked.response,
91+
release,
92+
});
93+
94+
const result = await executeChannelApi(
95+
{ method: "GET", path: "/guilds/123/channels" },
96+
{ accessToken: "token-1" },
97+
);
98+
99+
expect(result.details).toMatchObject({
100+
error: "503 Service Unavailable",
101+
status: 503,
102+
path: "/guilds/123/channels",
103+
});
104+
expect(JSON.stringify(result.details)).toContain("channel api unavailable");
105+
expect(JSON.stringify(result.details)).not.toContain("tail");
106+
expect(tracked.wasCanceled()).toBe(true);
107+
expect(textSpy).not.toHaveBeenCalled();
108+
expect(release).toHaveBeenCalledTimes(1);
109+
});
110+
});

extensions/qqbot/src/engine/tools/channel-api.ts

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,21 @@
88
* validation, fetch, and structured response formatting.
99
*/
1010

11+
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
12+
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
1113
import { formatErrorMessage } from "../utils/format.js";
1214
import { debugLog, debugError } from "../utils/log.js";
1315

1416
const API_BASE = "https://api.sgroup.qq.com";
1517
const DEFAULT_TIMEOUT_MS = 30000;
18+
const CHANNEL_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
19+
20+
function resolveChannelApiSsrfPolicy(url: string): SsrFPolicy {
21+
return {
22+
hostnameAllowlist: [new URL(url).hostname],
23+
allowRfc2544BenchmarkRange: true,
24+
};
25+
}
1626

1727
/**
1828
* Channel API call parameters.
@@ -174,8 +184,16 @@ export async function executeChannelApi(
174184
debugLog(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`);
175185

176186
let res: Response;
187+
let release: (() => Promise<void>) | undefined;
177188
try {
178-
res = await fetch(url, fetchOptions);
189+
const guarded = await fetchWithSsrFGuard({
190+
url,
191+
init: fetchOptions,
192+
auditContext: "qqbot-channel-api",
193+
policy: resolveChannelApiSsrfPolicy(url),
194+
});
195+
res = guarded.response;
196+
release = guarded.release;
179197
} catch (err) {
180198
clearTimeout(timeoutId);
181199
if (err instanceof Error && err.name === "AbortError") {
@@ -194,47 +212,53 @@ export async function executeChannelApi(
194212
clearTimeout(timeoutId);
195213
}
196214

197-
debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
215+
try {
216+
debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
198217

199-
const rawBody = await res.text();
200-
if (!rawBody || rawBody.trim() === "") {
201-
if (res.ok) {
202-
return json({ success: true, status: res.status, path: params.path });
218+
const rawBody = res.ok
219+
? await res.text()
220+
: await readResponseTextLimited(res, CHANNEL_API_ERROR_BODY_LIMIT_BYTES);
221+
if (!rawBody || rawBody.trim() === "") {
222+
if (res.ok) {
223+
return json({ success: true, status: res.status, path: params.path });
224+
}
225+
return json({
226+
error: `API returned ${res.status} ${res.statusText}`,
227+
status: res.status,
228+
path: params.path,
229+
});
203230
}
204-
return json({
205-
error: `API returned ${res.status} ${res.statusText}`,
206-
status: res.status,
207-
path: params.path,
208-
});
209-
}
210231

211-
let parsed: unknown;
212-
try {
213-
parsed = JSON.parse(rawBody);
214-
} catch {
215-
parsed = rawBody;
216-
}
232+
let parsed: unknown;
233+
try {
234+
parsed = JSON.parse(rawBody);
235+
} catch {
236+
parsed = rawBody;
237+
}
238+
239+
if (!res.ok) {
240+
const errMsg =
241+
typeof parsed === "object" && parsed && "message" in parsed
242+
? String((parsed as { message?: unknown }).message)
243+
: `${res.status} ${res.statusText}`;
244+
debugError(`[qqbot-channel-api] Error [${method} ${params.path}]: ${errMsg}`);
245+
return json({
246+
error: errMsg,
247+
status: res.status,
248+
path: params.path,
249+
details: parsed,
250+
});
251+
}
217252

218-
if (!res.ok) {
219-
const errMsg =
220-
typeof parsed === "object" && parsed && "message" in parsed
221-
? String((parsed as { message?: unknown }).message)
222-
: `${res.status} ${res.statusText}`;
223-
debugError(`[qqbot-channel-api] Error [${method} ${params.path}]: ${errMsg}`);
224253
return json({
225-
error: errMsg,
254+
success: true,
226255
status: res.status,
227256
path: params.path,
228-
details: parsed,
257+
data: parsed,
229258
});
259+
} finally {
260+
await release?.();
230261
}
231-
232-
return json({
233-
success: true,
234-
status: res.status,
235-
path: params.path,
236-
data: parsed,
237-
});
238262
} catch (err) {
239263
return json({
240264
error: formatErrorMessage(err),

0 commit comments

Comments
 (0)