mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 08:40:21 +00:00
fix: enforce inbound media max-bytes during remote fetch
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user