Files
openclaw/extensions/qqbot/src/utils/image-size.test.ts
Davanum Srinivas 08ae021d1f 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>
2026-04-09 16:48:04 +08:00

165 lines
5.2 KiB
TypeScript

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();
});
});