fix: enforce inbound media max-bytes during remote fetch

This commit is contained in:
Peter Steinberger
2026-02-21 23:02:17 +01:00
parent dd41fadcaf
commit 73d93dee64
10 changed files with 207 additions and 77 deletions

View File

@@ -1,18 +1,60 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
import type { BlueBubblesAttachment } from "./types.js";
const mockFetch = vi.fn();
const fetchRemoteMediaMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetchFn = params.fetchImpl ?? fetch;
const res = await fetchFn(params.url);
if (!res.ok) {
const text = await res.text().catch(() => "unknown");
throw new Error(
`Failed to fetch media from ${params.url}: HTTP ${res.status}; body: ${text}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
}
return {
buffer,
contentType: res.headers.get("content-type") ?? undefined,
fileName: undefined,
};
},
);
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
const runtimeStub = {
channel: {
media: {
fetchRemoteMedia:
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
},
},
} as unknown as PluginRuntime;
describe("downloadBlueBubblesAttachment", () => {
beforeEach(() => {
fetchRemoteMediaMock.mockClear();
mockFetch.mockReset();
setBlueBubblesRuntime(runtimeStub);
});
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(
@@ -120,7 +162,7 @@ describe("downloadBlueBubblesAttachment", () => {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("download failed (404): Attachment not found");
).rejects.toThrow("Attachment not found");
});
it("throws when attachment exceeds max bytes", async () => {
@@ -229,6 +271,8 @@ describe("sendBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
fetchRemoteMediaMock.mockClear();
setBlueBubblesRuntime(runtimeStub);
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";
import {
@@ -57,6 +58,19 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
return resolveBlueBubblesServerAccount(params);
}
function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
return String(input);
}
export async function downloadBlueBubblesAttachment(
attachment: BlueBubblesAttachment,
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
@@ -71,20 +85,30 @@ export async function downloadBlueBubblesAttachment(
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
);
}
const contentType = res.headers.get("content-type") ?? undefined;
const buf = new Uint8Array(await res.arrayBuffer());
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
if (buf.byteLength > maxBytes) {
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
try {
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
url,
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
maxBytes,
fetchImpl: async (input, init) =>
await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),
{ ...init, method: init?.method ?? "GET" },
opts.timeoutMs,
),
});
return {
buffer: new Uint8Array(fetched.buffer),
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
};
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
if (/(?:maxBytes|content length|payload exceeds)/i.test(text)) {
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
}
throw new Error(`BlueBubbles attachment download failed: ${text}`);
}
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
}
export type SendBlueBubblesAttachmentResult = {