diff --git a/extensions/qqbot/src/engine/api/token.test.ts b/extensions/qqbot/src/engine/api/token.test.ts index 2f88f2f1b76..7389fa4c760 100644 --- a/extensions/qqbot/src/engine/api/token.test.ts +++ b/extensions/qqbot/src/engine/api/token.test.ts @@ -42,6 +42,10 @@ describe("QQBot token manager", () => { url: "https://bots.qq.com/app/getAppAccessToken", auditContext: "qqbot-token", capture: false, + policy: { + hostnameAllowlist: ["bots.qq.com"], + allowRfc2544BenchmarkRange: true, + }, init: { method: "POST", headers: { @@ -54,6 +58,25 @@ describe("QQBot token manager", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("passes the RFC2544 SSRF allowance to the token fetch (regression for #88984)", async () => { + mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + + await expect(new TokenManager().getAccessToken("app-id", "secret")).resolves.toBe("token-1"); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://bots.qq.com/app/getAppAccessToken", + auditContext: "qqbot-token", + policy: { + hostnameAllowlist: ["bots.qq.com"], + allowRfc2544BenchmarkRange: true, + }, + }), + ); + }); + it("does not cache access tokens forever when expires_in is unsafe", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); diff --git a/extensions/qqbot/src/engine/api/token.ts b/extensions/qqbot/src/engine/api/token.ts index 299da9a0b4f..05325452522 100644 --- a/extensions/qqbot/src/engine/api/token.ts +++ b/extensions/qqbot/src/engine/api/token.ts @@ -12,13 +12,31 @@ import { resolveExpiresAtMsFromDurationSeconds, resolveTimestampMsToIsoString, } from "openclaw/plugin-sdk/number-runtime"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import type { EngineLogger } from "../types.js"; import { formatErrorMessage } from "../utils/format.js"; const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 7200; +/** + * Host-scoped SSRF policy for the QQ Bot token endpoint. + * + * `TOKEN_URL` is a hard-coded `https://bots.qq.com/...` constant, so this + * relaxation only ever applies to that single host. Fake-IP proxy stacks + * (sing-box, Clash, Surge, WSL2 DNS, etc.) routinely map `bots.qq.com` into + * the RFC 2544 benchmark range `198.18.0.0/15`, which the default SSRF + * guard blocks. We mirror the existing media-path pattern + * (`QQBOT_MEDIA_SSRF_POLICY` in `../utils/file-utils.ts`) so the relaxation + * stays narrowly host-scoped instead of weakening the global default. + * + * See https://github.com/openclaw/openclaw/issues/88984. + */ +const QQBOT_TOKEN_SSRF_POLICY: SsrFPolicy = { + hostnameAllowlist: ["bots.qq.com"], + allowRfc2544BenchmarkRange: true, +}; + interface CachedToken { token: string; expiresAt: number; @@ -234,6 +252,7 @@ export class TokenManager { url: TOKEN_URL, auditContext: "qqbot-token", capture: false, + policy: QQBOT_TOKEN_SSRF_POLICY, init: { method: "POST", headers: {