fix(qqbot): guard image-size probe against SSRF (#63495)

* fix(qqbot): replace raw fetch in image-size probe with SSRF-guarded fetchRemoteMedia

Replace the bare fetch() in getImageSizeFromUrl() with fetchRemoteMedia()
from the plugin SDK, closing the blind SSRF via markdown image dimension
probing (GHSA-2767-2q9v-9326).

fetchRemoteMedia options: maxBytes 65536, maxRedirects 0, generic
public-network-only SSRF policy (no hostname allowlist, blocks
private/reserved/loopback/link-local/metadata IPs after DNS resolution).

Also fixes the repo-root resolution in scripts/lib/ts-guard-utils.mjs
which caused lint:tmp:no-raw-channel-fetch to miss extension files
entirely. The guard now walks up to .git instead of hardcoding two parent
traversals, and the allowlist is refreshed with all pre-existing raw
fetch callsites that became visible.

* fix(qqbot): guard image-size probe against SSRF (#63495) (thanks @dims)

---------

Co-authored-by: sliverp <870080352@qq.com>
This commit is contained in:
Davanum Srinivas
2026-04-09 10:48:04 +02:00
committed by GitHub
parent ab49afcd27
commit 08ae021d1f
7 changed files with 378 additions and 63 deletions
+1
View File
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp/outbound queue: drain queued WhatsApp deliveries when the listener reconnects without dropping reconnect-delayed sends after a special TTL or rewriting retry history, so disconnect-window outbound messages can recover once the channel is ready again. (#46299) Thanks @manuel-claw.
- Tools/web_fetch: add an opt-in `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` config so fake-IP proxy environments that resolve public sites into `198.18.0.0/15` can use `web_fetch` without weakening the default SSRF block. (#61830) Thanks @xing-xing-coder.
- Config validation: surface the actual offending field for strict-schema union failures in bindings, including top-level unexpected keys on the matching ACP branch. (#40841) Thanks @Hollychou924.
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
## 2026.4.9
+60 -3
View File
@@ -48,10 +48,17 @@ vi.mock("./runtime.js", () => ({
}),
}));
vi.mock("./utils/image-size.js", () => ({
const imageSizeMocks = vi.hoisted(() => ({
getImageSize: vi.fn(),
formatQQBotMarkdownImage: vi.fn((url: string) => `![img](${url})`),
hasQQBotImageSize: vi.fn(() => false),
formatQQBotMarkdownImage: vi.fn(),
hasQQBotImageSize: vi.fn(),
}));
vi.mock("./utils/image-size.js", () => ({
getImageSize: (...args: unknown[]) => imageSizeMocks.getImageSize(...args),
formatQQBotMarkdownImage: (...args: unknown[]) =>
imageSizeMocks.formatQQBotMarkdownImage(...args),
hasQQBotImageSize: (...args: unknown[]) => imageSizeMocks.hasQQBotImageSize(...args),
}));
import {
@@ -95,6 +102,9 @@ describe("qqbot outbound deliver", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeMocks.chunkMarkdownText.mockImplementation((text: string) => [text]);
imageSizeMocks.getImageSize.mockResolvedValue(null);
imageSizeMocks.formatQQBotMarkdownImage.mockImplementation((url: string) => `![img](${url})`);
imageSizeMocks.hasQQBotImageSize.mockReturnValue(false);
});
it("sends plain replies through the shared text chunk sender", async () => {
@@ -168,4 +178,51 @@ describe("qqbot outbound deliver", () => {
);
expect(outboundMocks.sendPhoto).toHaveBeenCalledTimes(1);
});
describe("private-network image URL degradation", () => {
it("sends markdown reply with fallback dimensions when getImageSize returns null", async () => {
imageSizeMocks.getImageSize.mockResolvedValue(null);
await sendPlainReply(
{},
"Look at this: ![photo](https://10.0.0.1/internal.png)",
buildEvent(),
buildAccountContext(true),
sendWithRetry,
consumeQuoteRef,
[],
);
// getImageSize was called with the private-network URL
expect(imageSizeMocks.getImageSize).toHaveBeenCalledWith("https://10.0.0.1/internal.png");
// formatQQBotMarkdownImage was called with null size (triggers default dimensions)
expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith(
"https://10.0.0.1/internal.png",
null,
);
// Message was still sent (not crashed)
expect(apiMocks.sendC2CMessage).toHaveBeenCalled();
});
it("sends markdown reply with fallback when getImageSize throws", async () => {
imageSizeMocks.getImageSize.mockRejectedValue(new Error("SSRF blocked"));
await sendPlainReply(
{},
"Check ![img](https://169.254.169.254/latest/meta-data/)",
buildEvent(),
buildAccountContext(true),
sendWithRetry,
consumeQuoteRef,
[],
);
// formatQQBotMarkdownImage still called with null (catch path in outbound-deliver)
expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith(
"https://169.254.169.254/latest/meta-data/",
null,
);
expect(apiMocks.sendC2CMessage).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,164 @@
import { Buffer } from "buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mediaRuntimeMocks = vi.hoisted(() => ({
fetchRemoteMedia: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args),
}));
import { getImageSizeFromUrl, parseImageSize } from "./image-size.js";
/** Build a minimal valid PNG header with the given dimensions. */
function buildPngHeader(width: number, height: number): Buffer {
const buf = Buffer.alloc(24);
// PNG signature
buf[0] = 0x89;
buf[1] = 0x50;
buf[2] = 0x4e;
buf[3] = 0x47;
buf[4] = 0x0d;
buf[5] = 0x0a;
buf[6] = 0x1a;
buf[7] = 0x0a;
// IHDR chunk length
buf.writeUInt32BE(13, 8);
// "IHDR"
buf.write("IHDR", 12, "ascii");
// Width and height
buf.writeUInt32BE(width, 16);
buf.writeUInt32BE(height, 20);
return buf;
}
describe("getImageSizeFromUrl", () => {
beforeEach(() => {
mediaRuntimeMocks.fetchRemoteMedia.mockReset();
});
describe("fetchRemoteMedia options contract", () => {
it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
buffer: buildPngHeader(800, 600),
contentType: "image/png",
});
await getImageSizeFromUrl("https://cdn.example.com/photo.png");
expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledOnce();
const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0];
expect(opts.url).toBe("https://cdn.example.com/photo.png");
expect(opts.maxBytes).toBe(65_536);
expect(opts.maxRedirects).toBe(0);
// Generic public-network-only policy: no hostname allowlist
expect(opts.ssrfPolicy).toEqual({});
expect(opts.requestInit.headers).toEqual({
Range: "bytes=0-65535",
"User-Agent": "QQBot-Image-Size-Detector/1.0",
});
});
it("threads caller abort signal through requestInit", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
buffer: buildPngHeader(100, 100),
});
await getImageSizeFromUrl("https://cdn.example.com/img.png", 3000);
const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0];
expect(opts.requestInit.signal).toBeInstanceOf(AbortSignal);
});
});
describe("SSRF blocking (fetchRemoteMedia rejects)", () => {
it("returns null when fetchRemoteMedia throws for loopback", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
new Error("SSRF blocked: loopback address"),
);
const result = await getImageSizeFromUrl("https://127.0.0.1/img.png");
expect(result).toBeNull();
});
it("returns null when fetchRemoteMedia throws for IPv6 loopback", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
new Error("SSRF blocked: loopback address"),
);
const result = await getImageSizeFromUrl("https://[::1]/img.png");
expect(result).toBeNull();
});
it("returns null when fetchRemoteMedia throws for link-local/metadata", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
new Error("SSRF blocked: link-local address"),
);
const result = await getImageSizeFromUrl("https://169.254.169.254/latest/meta-data/");
expect(result).toBeNull();
});
it("returns null when fetchRemoteMedia throws for RFC1918 addresses", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
new Error("SSRF blocked: private address"),
);
const result = await getImageSizeFromUrl("https://10.0.0.1/img.png");
expect(result).toBeNull();
});
it("returns null on http error from fetchRemoteMedia", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden"));
const result = await getImageSizeFromUrl("https://cdn.example.com/forbidden.png");
expect(result).toBeNull();
});
});
describe("happy path", () => {
it("returns parsed dimensions for a valid PNG", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
buffer: buildPngHeader(1920, 1080),
contentType: "image/png",
});
const size = await getImageSizeFromUrl("https://cdn.example.com/banner.png");
expect(size).toEqual({ width: 1920, height: 1080 });
});
it("returns null when the buffer is not a recognized image format", async () => {
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("not an image"),
contentType: "text/html",
});
const size = await getImageSizeFromUrl("https://cdn.example.com/notimage.html");
expect(size).toBeNull();
});
});
});
describe("parseImageSize", () => {
it("parses PNG dimensions", () => {
const size = parseImageSize(buildPngHeader(640, 480));
expect(size).toEqual({ width: 640, height: 480 });
});
it("returns null for unrecognized data", () => {
expect(parseImageSize(Buffer.from("hello"))).toBeNull();
});
it("returns null for empty buffer", () => {
expect(parseImageSize(Buffer.alloc(0))).toBeNull();
});
});
+35 -25
View File
@@ -5,6 +5,8 @@
*/
import { Buffer } from "buffer";
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { debugLog } from "./debug-log.js";
export interface ImageSize {
@@ -143,8 +145,18 @@ export function parseImageSize(buffer: Buffer): ImageSize | null {
);
}
/**
* SSRF policy for image-dimension probing. Generic public-network-only blocking
* (no hostname allowlist) because markdown image URLs can legitimately point to
* any public host, not just QQ-owned CDNs.
*/
const IMAGE_PROBE_SSRF_POLICY: SsrFPolicy = {};
/**
* Fetch image dimensions from a public URL using only the first 64 KB.
*
* Uses {@link fetchRemoteMedia} with SSRF guard to block probes against
* private/reserved/loopback/link-local/metadata destinations.
*/
export async function getImageSizeFromUrl(
url: string,
@@ -154,33 +166,31 @@ export async function getImageSizeFromUrl(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
// Request only the first 64 KB, which is enough for common headers.
const response = await fetch(url, {
signal: controller.signal,
headers: {
Range: "bytes=0-65535",
"User-Agent": "QQBot-Image-Size-Detector/1.0",
},
});
try {
const { buffer } = await fetchRemoteMedia({
url,
maxBytes: 65_536,
maxRedirects: 0,
ssrfPolicy: IMAGE_PROBE_SSRF_POLICY,
requestInit: {
signal: controller.signal,
headers: {
Range: "bytes=0-65535",
"User-Agent": "QQBot-Image-Size-Detector/1.0",
},
},
});
clearTimeout(timeoutId);
if (!response.ok && response.status !== 206) {
debugLog(`[image-size] Failed to fetch ${url}: ${response.status}`);
return null;
const size = parseImageSize(buffer);
if (size) {
debugLog(
`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`,
);
}
return size;
} finally {
clearTimeout(timeoutId);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const size = parseImageSize(buffer);
if (size) {
debugLog(
`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`,
);
}
return size;
} catch (err) {
debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${String(err)}`);
return null;
+54 -34
View File
@@ -14,41 +14,61 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"];
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("bluebubbles", "src/types.ts", 133),
bundledPluginCallsite("feishu", "src/streaming-card.ts", 31),
bundledPluginCallsite("feishu", "src/streaming-card.ts", 101),
bundledPluginCallsite("feishu", "src/streaming-card.ts", 143),
bundledPluginCallsite("feishu", "src/streaming-card.ts", 199),
bundledPluginCallsite("googlechat", "src/api.ts", 22),
bundledPluginCallsite("googlechat", "src/api.ts", 43),
bundledPluginCallsite("googlechat", "src/api.ts", 63),
bundledPluginCallsite("googlechat", "src/api.ts", 188),
bundledPluginCallsite("googlechat", "src/auth.ts", 82),
bundledPluginCallsite("matrix", "src/directory-live.ts", 41),
bundledPluginCallsite("matrix", "src/matrix/client/config.ts", 171),
bundledPluginCallsite("mattermost", "src/mattermost/client.ts", 211),
bundledPluginCallsite("mattermost", "src/mattermost/monitor.ts", 230),
bundledPluginCallsite("mattermost", "src/mattermost/probe.ts", 27),
bundledPluginCallsite("minimax", "oauth.ts", 62),
bundledPluginCallsite("minimax", "oauth.ts", 93),
bundledPluginCallsite("msteams", "src/graph.ts", 39),
bundledPluginCallsite("nextcloud-talk", "src/room-info.ts", 92),
bundledPluginCallsite("nextcloud-talk", "src/send.ts", 107),
bundledPluginCallsite("nextcloud-talk", "src/send.ts", 198),
bundledPluginCallsite("talk-voice", "index.ts", 27),
bundledPluginCallsite("thread-ownership", "index.ts", 105),
bundledPluginCallsite("voice-call", "src/providers/plivo.ts", 95),
bundledPluginCallsite("voice-call", "src/providers/telnyx.ts", 61),
bundledPluginCallsite("voice-call", "src/providers/tts-openai.ts", 111),
bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 128),
bundledPluginCallsite("bluebubbles", "src/types.ts", 181),
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 235),
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 217),
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24),
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 27),
bundledPluginCallsite("chutes", "models.ts", 535),
bundledPluginCallsite("chutes", "models.ts", 542),
bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 322),
bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 360),
bundledPluginCallsite("discord", "src/voice-message.ts", 298),
bundledPluginCallsite("discord", "src/voice-message.ts", 333),
bundledPluginCallsite("elevenlabs", "speech-provider.ts", 295),
bundledPluginCallsite("elevenlabs", "tts.ts", 116),
bundledPluginCallsite("feishu", "src/monitor.webhook.test-helpers.ts", 25),
bundledPluginCallsite("github-copilot", "login.ts", 48),
bundledPluginCallsite("github-copilot", "login.ts", 80),
bundledPluginCallsite("googlechat", "src/auth.ts", 83),
bundledPluginCallsite("huggingface", "models.ts", 142),
bundledPluginCallsite("kilocode", "provider-models.ts", 130),
bundledPluginCallsite("matrix", "src/matrix/sdk/transport.ts", 112),
bundledPluginCallsite("microsoft-foundry", "onboard.ts", 479),
bundledPluginCallsite("microsoft", "speech-provider.ts", 132),
bundledPluginCallsite("minimax", "oauth.ts", 66),
bundledPluginCallsite("minimax", "oauth.ts", 107),
bundledPluginCallsite("minimax", "tts.ts", 52),
bundledPluginCallsite("msteams", "src/graph.ts", 47),
bundledPluginCallsite("msteams", "src/sdk.ts", 292),
bundledPluginCallsite("msteams", "src/sdk.ts", 333),
bundledPluginCallsite("ollama", "src/stream.ts", 649),
bundledPluginCallsite("openai", "tts.ts", 133),
bundledPluginCallsite("qa-channel", "src/bus-client.ts", 41),
bundledPluginCallsite("qa-channel", "src/bus-client.ts", 221),
bundledPluginCallsite("qa-lab", "src/docker-up.runtime.ts", 274),
bundledPluginCallsite("qa-lab", "src/gateway-child.ts", 488),
bundledPluginCallsite("qa-lab", "src/suite.ts", 330),
bundledPluginCallsite("qa-lab", "src/suite.ts", 341),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 15),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 23),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 31),
bundledPluginCallsite("qqbot", "src/api.ts", 102),
bundledPluginCallsite("qqbot", "src/api.ts", 237),
bundledPluginCallsite("qqbot", "src/stt.ts", 81),
bundledPluginCallsite("qqbot", "src/tools/channel.ts", 180),
bundledPluginCallsite("qqbot", "src/utils/audio-convert.ts", 377),
bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224),
bundledPluginCallsite("slack", "src/monitor/media.ts", 96),
bundledPluginCallsite("slack", "src/monitor/media.ts", 115),
bundledPluginCallsite("slack", "src/monitor/media.ts", 120),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 185),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 235),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 289),
bundledPluginCallsite("venice", "models.ts", 552),
bundledPluginCallsite("vercel-ai-gateway", "models.ts", 181),
bundledPluginCallsite("voice-call", "src/providers/twilio/api.ts", 23),
bundledPluginCallsite("telegram", "src/api-fetch.ts", 8),
bundledPluginCallsite("discord", "src/send.outbound.ts", 363),
bundledPluginCallsite("discord", "src/voice-message.ts", 268),
bundledPluginCallsite("discord", "src/voice-message.ts", 312),
bundledPluginCallsite("slack", "src/monitor/media.ts", 55),
bundledPluginCallsite("slack", "src/monitor/media.ts", 59),
bundledPluginCallsite("slack", "src/monitor/media.ts", 73),
bundledPluginCallsite("slack", "src/monitor/media.ts", 99),
]);
function isRawFetchCall(expression) {
+13 -1
View File
@@ -1,4 +1,4 @@
import { promises as fs } from "node:fs";
import { existsSync, promises as fs } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -14,6 +14,18 @@ function getTypeScript() {
const baseTestSuffixes = [".test.ts", ".test-utils.ts", ".test-harness.ts", ".e2e-harness.ts"];
export function resolveRepoRoot(importMetaUrl) {
// Walk up from the caller's directory until we find the repo root (.git).
// This handles callers at any depth (scripts/*.mjs, scripts/lib/*.mjs, etc.)
// instead of assuming a fixed number of parent traversals.
let dir = path.dirname(fileURLToPath(importMetaUrl));
const { root } = path.parse(dir);
while (dir !== root) {
if (existsSync(path.join(dir, ".git"))) {
return dir;
}
dir = path.dirname(dir);
}
// Fallback: two levels up (original behavior).
return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", "..");
}
+51
View File
@@ -0,0 +1,51 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveRepoRoot } from "../../scripts/lib/ts-guard-utils.mjs";
/**
* Regression tests for resolveRepoRoot().
*
* The original implementation went up exactly two levels from the caller's
* import.meta.url, which broke for scripts at scripts/*.mjs (one level below
* root) — it overshot to the repo's parent directory.
*/
describe("resolveRepoRoot", () => {
it("resolves correctly from a scripts/lib/*.mjs path (two levels below root)", () => {
const fakeUrl = pathToFileURL(path.resolve("scripts", "lib", "some-guard-utils.mjs")).href;
const root = resolveRepoRoot(fakeUrl);
expect(existsSync(path.join(root, ".git"))).toBe(true);
expect(existsSync(path.join(root, "package.json"))).toBe(true);
});
it("resolves correctly from a scripts/*.mjs path (one level below root)", () => {
const fakeUrl = pathToFileURL(path.resolve("scripts", "check-no-raw-channel-fetch.mjs")).href;
const root = resolveRepoRoot(fakeUrl);
expect(existsSync(path.join(root, ".git"))).toBe(true);
expect(existsSync(path.join(root, "package.json"))).toBe(true);
});
it("resolves correctly from a deeply nested extension path", () => {
const fakeUrl = pathToFileURL(
path.resolve("extensions", "qqbot", "src", "utils", "hypothetical.mjs"),
).href;
const root = resolveRepoRoot(fakeUrl);
expect(existsSync(path.join(root, ".git"))).toBe(true);
expect(existsSync(path.join(root, "package.json"))).toBe(true);
});
it("all caller depths resolve to the same root", () => {
const fromLib = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "lib", "a.mjs")).href);
const fromScripts = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "b.mjs")).href);
const fromExtension = resolveRepoRoot(
pathToFileURL(path.resolve("extensions", "qqbot", "c.mjs")).href,
);
expect(fromLib).toBe(fromScripts);
expect(fromScripts).toBe(fromExtension);
});
});