Skip to content

Commit 2ddff84

Browse files
committed
fix(qqbot): guard token fetches
1 parent 4b08426 commit 2ddff84

2 files changed

Lines changed: 81 additions & 47 deletions

File tree

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,46 @@
1-
import { afterEach, describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { TokenManager } from "./token.js";
33

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+
414
describe("QQBot token manager", () => {
5-
afterEach(() => {
6-
vi.unstubAllGlobals();
15+
beforeEach(() => {
16+
fetchWithSsrFGuardMock.mockReset();
717
});
818

919
it("wraps malformed access token JSON", async () => {
10-
vi.stubGlobal(
11-
"fetch",
12-
vi.fn().mockResolvedValue(
13-
new Response("{not json", {
14-
status: 200,
15-
headers: { "content-type": "application/json" },
16-
}),
17-
),
18-
);
20+
const release = vi.fn(async () => {});
21+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
22+
response: new Response("{not json", {
23+
status: 200,
24+
headers: { "content-type": "application/json" },
25+
}),
26+
release,
27+
});
1928

2029
await expect(new TokenManager().getAccessToken("app-id", "secret")).rejects.toThrow(
2130
"QQBot access_token response was malformed JSON",
2231
);
32+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
33+
url: "https://bots.qq.com/app/getAppAccessToken",
34+
auditContext: "qqbot-token",
35+
init: {
36+
method: "POST",
37+
headers: {
38+
"Content-Type": "application/json",
39+
"User-Agent": "QQBotPlugin/unknown",
40+
},
41+
body: JSON.stringify({ appId: "app-id", clientSecret: "secret" }),
42+
},
43+
});
44+
expect(release).toHaveBeenCalledTimes(1);
2345
});
2446
});

extensions/qqbot/src/engine/api/token.ts

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* globals, fully supporting multi-account concurrent operation.
77
*/
88

9+
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
910
import type { EngineLogger } from "../types.js";
1011
import { formatErrorMessage } from "../utils/format.js";
1112

@@ -207,56 +208,67 @@ export class TokenManager {
207208
this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`);
208209

209210
let response: Response;
211+
let release: (() => Promise<void>) | undefined;
210212
try {
211-
response = await fetch(TOKEN_URL, {
212-
method: "POST",
213-
headers: {
214-
"Content-Type": "application/json",
215-
"User-Agent": this.resolveUserAgent(),
213+
const guarded = await fetchWithSsrFGuard({
214+
url: TOKEN_URL,
215+
auditContext: "qqbot-token",
216+
init: {
217+
method: "POST",
218+
headers: {
219+
"Content-Type": "application/json",
220+
"User-Agent": this.resolveUserAgent(),
221+
},
222+
body: JSON.stringify({ appId, clientSecret }),
216223
},
217-
body: JSON.stringify({ appId, clientSecret }),
218224
});
225+
response = guarded.response;
226+
release = guarded.release;
219227
} catch (err) {
220228
this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`);
221229
throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, {
222230
cause: err,
223231
});
224232
}
225233

226-
const traceId = response.headers.get("x-tps-trace-id") ?? "";
227-
this.logger?.debug?.(
228-
`[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`,
229-
);
230-
231-
let rawBody: string;
232234
try {
233-
rawBody = await response.text();
234-
} catch (err) {
235-
throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, {
236-
cause: err,
237-
});
238-
}
239-
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
240-
this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`);
235+
const traceId = response.headers.get("x-tps-trace-id") ?? "";
236+
this.logger?.debug?.(
237+
`[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`,
238+
);
241239

242-
let data: { access_token?: string; expires_in?: number };
243-
try {
244-
data = JSON.parse(rawBody);
245-
} catch {
246-
throw new Error("QQBot access_token response was malformed JSON");
247-
}
240+
let rawBody: string;
241+
try {
242+
rawBody = await response.text();
243+
} catch (err) {
244+
throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, {
245+
cause: err,
246+
});
247+
}
248+
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
249+
this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`);
248250

249-
if (!data.access_token) {
250-
throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
251-
}
251+
let data: { access_token?: string; expires_in?: number };
252+
try {
253+
data = JSON.parse(rawBody);
254+
} catch {
255+
throw new Error("QQBot access_token response was malformed JSON");
256+
}
252257

253-
const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
254-
this.cache.set(appId, { token: data.access_token, expiresAt, appId });
255-
this.logger?.debug?.(
256-
`[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`,
257-
);
258+
if (!data.access_token) {
259+
throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
260+
}
261+
262+
const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
263+
this.cache.set(appId, { token: data.access_token, expiresAt, appId });
264+
this.logger?.debug?.(
265+
`[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`,
266+
);
258267

259-
return data.access_token;
268+
return data.access_token;
269+
} finally {
270+
await release?.();
271+
}
260272
}
261273

262274
private abortableSleep(ms: number, signal: AbortSignal): Promise<void> {

0 commit comments

Comments
 (0)