mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 02:22:46 +00:00
fix(qqbot): guard channel api fetches
This commit is contained in:
110
extensions/qqbot/src/engine/tools/channel-api.test.ts
Normal file
110
extensions/qqbot/src/engine/tools/channel-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user