@openclaw/bluebubbles 2026.2.13 → 2026.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { sendBlueBubblesMedia } from "./media-send.js";
8
+ import { setBlueBubblesRuntime } from "./runtime.js";
9
+
10
+ const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn());
11
+ const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn());
12
+ const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn((id: string) => id));
13
+
14
+ vi.mock("./attachments.js", () => ({
15
+ sendBlueBubblesAttachment: sendBlueBubblesAttachmentMock,
16
+ }));
17
+
18
+ vi.mock("./send.js", () => ({
19
+ sendMessageBlueBubbles: sendMessageBlueBubblesMock,
20
+ }));
21
+
22
+ vi.mock("./monitor.js", () => ({
23
+ resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock,
24
+ }));
25
+
26
+ type RuntimeMocks = {
27
+ detectMime: ReturnType<typeof vi.fn>;
28
+ fetchRemoteMedia: ReturnType<typeof vi.fn>;
29
+ };
30
+
31
+ let runtimeMocks: RuntimeMocks;
32
+ const tempDirs: string[] = [];
33
+
34
+ function createMockRuntime(): { runtime: PluginRuntime; mocks: RuntimeMocks } {
35
+ const detectMime = vi.fn().mockResolvedValue("text/plain");
36
+ const fetchRemoteMedia = vi.fn().mockResolvedValue({
37
+ buffer: new Uint8Array([1, 2, 3]),
38
+ contentType: "image/png",
39
+ fileName: "remote.png",
40
+ });
41
+ return {
42
+ runtime: {
43
+ version: "1.0.0",
44
+ media: {
45
+ detectMime,
46
+ },
47
+ channel: {
48
+ media: {
49
+ fetchRemoteMedia,
50
+ },
51
+ },
52
+ } as unknown as PluginRuntime,
53
+ mocks: { detectMime, fetchRemoteMedia },
54
+ };
55
+ }
56
+
57
+ function createConfig(overrides?: Record<string, unknown>): OpenClawConfig {
58
+ return {
59
+ channels: {
60
+ bluebubbles: {
61
+ ...overrides,
62
+ },
63
+ },
64
+ } as unknown as OpenClawConfig;
65
+ }
66
+
67
+ async function makeTempDir(): Promise<string> {
68
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-bb-media-"));
69
+ tempDirs.push(dir);
70
+ return dir;
71
+ }
72
+
73
+ beforeEach(() => {
74
+ const runtime = createMockRuntime();
75
+ runtimeMocks = runtime.mocks;
76
+ setBlueBubblesRuntime(runtime.runtime);
77
+ sendBlueBubblesAttachmentMock.mockReset();
78
+ sendBlueBubblesAttachmentMock.mockResolvedValue({ messageId: "msg-1" });
79
+ sendMessageBlueBubblesMock.mockReset();
80
+ sendMessageBlueBubblesMock.mockResolvedValue({ messageId: "msg-caption" });
81
+ resolveBlueBubblesMessageIdMock.mockClear();
82
+ });
83
+
84
+ afterEach(async () => {
85
+ while (tempDirs.length > 0) {
86
+ const dir = tempDirs.pop();
87
+ if (!dir) {
88
+ continue;
89
+ }
90
+ await fs.rm(dir, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ describe("sendBlueBubblesMedia local-path hardening", () => {
95
+ it("rejects local paths when mediaLocalRoots is not configured", async () => {
96
+ await expect(
97
+ sendBlueBubblesMedia({
98
+ cfg: createConfig(),
99
+ to: "chat:123",
100
+ mediaPath: "/etc/passwd",
101
+ }),
102
+ ).rejects.toThrow(/mediaLocalRoots/i);
103
+
104
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("rejects local paths outside configured mediaLocalRoots", async () => {
108
+ const allowedRoot = await makeTempDir();
109
+ const outsideDir = await makeTempDir();
110
+ const outsideFile = path.join(outsideDir, "outside.txt");
111
+ await fs.writeFile(outsideFile, "not allowed", "utf8");
112
+
113
+ await expect(
114
+ sendBlueBubblesMedia({
115
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
116
+ to: "chat:123",
117
+ mediaPath: outsideFile,
118
+ }),
119
+ ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
120
+
121
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("allows local paths that are explicitly configured", async () => {
125
+ const allowedRoot = await makeTempDir();
126
+ const allowedFile = path.join(allowedRoot, "allowed.txt");
127
+ await fs.writeFile(allowedFile, "allowed", "utf8");
128
+
129
+ const result = await sendBlueBubblesMedia({
130
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
131
+ to: "chat:123",
132
+ mediaPath: allowedFile,
133
+ });
134
+
135
+ expect(result).toEqual({ messageId: "msg-1" });
136
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
137
+ expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
138
+ expect.objectContaining({
139
+ filename: "allowed.txt",
140
+ contentType: "text/plain",
141
+ }),
142
+ );
143
+ expect(runtimeMocks.detectMime).toHaveBeenCalled();
144
+ });
145
+
146
+ it("allows file:// media paths and file:// local roots", async () => {
147
+ const allowedRoot = await makeTempDir();
148
+ const allowedFile = path.join(allowedRoot, "allowed.txt");
149
+ await fs.writeFile(allowedFile, "allowed", "utf8");
150
+
151
+ const result = await sendBlueBubblesMedia({
152
+ cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }),
153
+ to: "chat:123",
154
+ mediaPath: pathToFileURL(allowedFile).toString(),
155
+ });
156
+
157
+ expect(result).toEqual({ messageId: "msg-1" });
158
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
159
+ expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
160
+ expect.objectContaining({
161
+ filename: "allowed.txt",
162
+ }),
163
+ );
164
+ });
165
+
166
+ it("uses account-specific mediaLocalRoots over top-level roots", async () => {
167
+ const baseRoot = await makeTempDir();
168
+ const accountRoot = await makeTempDir();
169
+ const baseFile = path.join(baseRoot, "base.txt");
170
+ const accountFile = path.join(accountRoot, "account.txt");
171
+ await fs.writeFile(baseFile, "base", "utf8");
172
+ await fs.writeFile(accountFile, "account", "utf8");
173
+
174
+ const cfg = createConfig({
175
+ mediaLocalRoots: [baseRoot],
176
+ accounts: {
177
+ work: {
178
+ mediaLocalRoots: [accountRoot],
179
+ },
180
+ },
181
+ });
182
+
183
+ await expect(
184
+ sendBlueBubblesMedia({
185
+ cfg,
186
+ to: "chat:123",
187
+ accountId: "work",
188
+ mediaPath: baseFile,
189
+ }),
190
+ ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
191
+
192
+ const result = await sendBlueBubblesMedia({
193
+ cfg,
194
+ to: "chat:123",
195
+ accountId: "work",
196
+ mediaPath: accountFile,
197
+ });
198
+
199
+ expect(result).toEqual({ messageId: "msg-1" });
200
+ });
201
+
202
+ it("rejects symlink escapes under an allowed root", async () => {
203
+ const allowedRoot = await makeTempDir();
204
+ const outsideDir = await makeTempDir();
205
+ const outsideFile = path.join(outsideDir, "secret.txt");
206
+ const linkPath = path.join(allowedRoot, "link.txt");
207
+ await fs.writeFile(outsideFile, "secret", "utf8");
208
+
209
+ try {
210
+ await fs.symlink(outsideFile, linkPath);
211
+ } catch {
212
+ // Some environments disallow symlink creation; skip without failing the suite.
213
+ return;
214
+ }
215
+
216
+ await expect(
217
+ sendBlueBubblesMedia({
218
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
219
+ to: "chat:123",
220
+ mediaPath: linkPath,
221
+ }),
222
+ ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
223
+
224
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it("rejects relative mediaLocalRoots entries", async () => {
228
+ const allowedRoot = await makeTempDir();
229
+ const allowedFile = path.join(allowedRoot, "allowed.txt");
230
+ const relativeRoot = path.relative(process.cwd(), allowedRoot);
231
+ await fs.writeFile(allowedFile, "allowed", "utf8");
232
+
233
+ await expect(
234
+ sendBlueBubblesMedia({
235
+ cfg: createConfig({ mediaLocalRoots: [relativeRoot] }),
236
+ to: "chat:123",
237
+ mediaPath: allowedFile,
238
+ }),
239
+ ).rejects.toThrow(/must be absolute paths/i);
240
+
241
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it("keeps remote URL flow unchanged", async () => {
245
+ await sendBlueBubblesMedia({
246
+ cfg: createConfig(),
247
+ to: "chat:123",
248
+ mediaUrl: "https://example.com/file.png",
249
+ });
250
+
251
+ expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith(
252
+ expect.objectContaining({ url: "https://example.com/file.png" }),
253
+ );
254
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
255
+ });
256
+ });
package/src/media-send.ts CHANGED
@@ -1,6 +1,10 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
1
4
  import path from "node:path";
2
5
  import { fileURLToPath } from "node:url";
3
6
  import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
7
+ import { resolveBlueBubblesAccount } from "./accounts.js";
4
8
  import { sendBlueBubblesAttachment } from "./attachments.js";
5
9
  import { resolveBlueBubblesMessageId } from "./monitor.js";
6
10
  import { getBlueBubblesRuntime } from "./runtime.js";
@@ -32,6 +36,141 @@ function resolveLocalMediaPath(source: string): string {
32
36
  }
33
37
  }
34
38
 
39
+ function expandHomePath(input: string): string {
40
+ if (input === "~") {
41
+ return os.homedir();
42
+ }
43
+ if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) {
44
+ return path.join(os.homedir(), input.slice(2));
45
+ }
46
+ return input;
47
+ }
48
+
49
+ function resolveConfiguredPath(input: string): string {
50
+ const trimmed = input.trim();
51
+ if (!trimmed) {
52
+ throw new Error("Empty mediaLocalRoots entry is not allowed");
53
+ }
54
+ if (trimmed.startsWith("file://")) {
55
+ let parsed: string;
56
+ try {
57
+ parsed = fileURLToPath(trimmed);
58
+ } catch {
59
+ throw new Error(`Invalid file:// URL in mediaLocalRoots: ${input}`);
60
+ }
61
+ if (!path.isAbsolute(parsed)) {
62
+ throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
63
+ }
64
+ return parsed;
65
+ }
66
+ const resolved = expandHomePath(trimmed);
67
+ if (!path.isAbsolute(resolved)) {
68
+ throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
69
+ }
70
+ return resolved;
71
+ }
72
+
73
+ function isPathInsideRoot(candidate: string, root: string): boolean {
74
+ const normalizedCandidate = path.normalize(candidate);
75
+ const normalizedRoot = path.normalize(root);
76
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
77
+ ? normalizedRoot
78
+ : normalizedRoot + path.sep;
79
+ if (process.platform === "win32") {
80
+ const candidateLower = normalizedCandidate.toLowerCase();
81
+ const rootLower = normalizedRoot.toLowerCase();
82
+ const rootWithSepLower = rootWithSep.toLowerCase();
83
+ return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower);
84
+ }
85
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
86
+ }
87
+
88
+ function resolveMediaLocalRoots(params: { cfg: OpenClawConfig; accountId?: string }): string[] {
89
+ const account = resolveBlueBubblesAccount({
90
+ cfg: params.cfg,
91
+ accountId: params.accountId,
92
+ });
93
+ return (account.config.mediaLocalRoots ?? [])
94
+ .map((entry) => entry.trim())
95
+ .filter((entry) => entry.length > 0);
96
+ }
97
+
98
+ async function assertLocalMediaPathAllowed(params: {
99
+ localPath: string;
100
+ localRoots: string[];
101
+ accountId?: string;
102
+ }): Promise<{ data: Buffer; realPath: string; sizeBytes: number }> {
103
+ if (params.localRoots.length === 0) {
104
+ throw new Error(
105
+ `Local BlueBubbles media paths are disabled by default. Set channels.bluebubbles.mediaLocalRoots${
106
+ params.accountId
107
+ ? ` or channels.bluebubbles.accounts.${params.accountId}.mediaLocalRoots`
108
+ : ""
109
+ } to explicitly allow local file directories.`,
110
+ );
111
+ }
112
+
113
+ const resolvedLocalPath = path.resolve(params.localPath);
114
+ const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
115
+ const openFlags = fsConstants.O_RDONLY | (supportsNoFollow ? fsConstants.O_NOFOLLOW : 0);
116
+
117
+ for (const rootEntry of params.localRoots) {
118
+ const resolvedRootInput = resolveConfiguredPath(rootEntry);
119
+ const relativeToRoot = path.relative(resolvedRootInput, resolvedLocalPath);
120
+ if (
121
+ relativeToRoot.startsWith("..") ||
122
+ path.isAbsolute(relativeToRoot) ||
123
+ relativeToRoot === ""
124
+ ) {
125
+ continue;
126
+ }
127
+
128
+ let rootReal: string;
129
+ try {
130
+ rootReal = await fs.realpath(resolvedRootInput);
131
+ } catch {
132
+ rootReal = path.resolve(resolvedRootInput);
133
+ }
134
+ const candidatePath = path.resolve(rootReal, relativeToRoot);
135
+
136
+ if (!isPathInsideRoot(candidatePath, rootReal)) {
137
+ continue;
138
+ }
139
+
140
+ let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
141
+ try {
142
+ handle = await fs.open(candidatePath, openFlags);
143
+ const realPath = await fs.realpath(candidatePath);
144
+ if (!isPathInsideRoot(realPath, rootReal)) {
145
+ continue;
146
+ }
147
+
148
+ const stat = await handle.stat();
149
+ if (!stat.isFile()) {
150
+ continue;
151
+ }
152
+ const realStat = await fs.stat(realPath);
153
+ if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
154
+ continue;
155
+ }
156
+
157
+ const data = await handle.readFile();
158
+ return { data, realPath, sizeBytes: stat.size };
159
+ } catch {
160
+ // Try next configured root.
161
+ continue;
162
+ } finally {
163
+ if (handle) {
164
+ await handle.close().catch(() => {});
165
+ }
166
+ }
167
+ }
168
+
169
+ throw new Error(
170
+ `Local media path is not under any configured mediaLocalRoots entry: ${params.localPath}`,
171
+ );
172
+ }
173
+
35
174
  function resolveFilenameFromSource(source?: string): string | undefined {
36
175
  if (!source) {
37
176
  return undefined;
@@ -88,6 +227,7 @@ export async function sendBlueBubblesMedia(params: {
88
227
  cfg.channels?.bluebubbles?.mediaMaxMb,
89
228
  accountId,
90
229
  });
230
+ const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });
91
231
 
92
232
  let buffer: Uint8Array;
93
233
  let resolvedContentType = contentType ?? undefined;
@@ -121,24 +261,27 @@ export async function sendBlueBubblesMedia(params: {
121
261
  resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
122
262
  resolvedFilename = resolvedFilename ?? fetched.fileName;
123
263
  } else {
124
- const localPath = resolveLocalMediaPath(source);
125
- const fs = await import("node:fs/promises");
264
+ const localPath = expandHomePath(resolveLocalMediaPath(source));
265
+ const localFile = await assertLocalMediaPathAllowed({
266
+ localPath,
267
+ localRoots: mediaLocalRoots,
268
+ accountId,
269
+ });
126
270
  if (typeof maxBytes === "number" && maxBytes > 0) {
127
- const stats = await fs.stat(localPath);
128
- assertMediaWithinLimit(stats.size, maxBytes);
271
+ assertMediaWithinLimit(localFile.sizeBytes, maxBytes);
129
272
  }
130
- const data = await fs.readFile(localPath);
273
+ const data = localFile.data;
131
274
  assertMediaWithinLimit(data.byteLength, maxBytes);
132
275
  buffer = new Uint8Array(data);
133
276
  if (!resolvedContentType) {
134
277
  const detected = await core.media.detectMime({
135
278
  buffer: data,
136
- filePath: localPath,
279
+ filePath: localFile.realPath,
137
280
  });
138
281
  resolvedContentType = detected ?? undefined;
139
282
  }
140
283
  if (!resolvedFilename) {
141
- resolvedFilename = resolveFilenameFromSource(localPath);
284
+ resolvedFilename = resolveFilenameFromSource(localFile.realPath);
142
285
  }
143
286
  }
144
287
  }