From 51fddd20b5103b4b1f79d19858a294057214c3c0 Mon Sep 17 00:00:00 2001 From: saurav470 Date: Sun, 8 Mar 2026 11:07:35 +0530 Subject: [PATCH 1/5] feat(feishu): add localRoots configuration for media uploads and enhance media handling tests --- extensions/feishu/src/config-schema.ts | 2 ++ extensions/feishu/src/media.test.ts | 47 ++++++++++++++++++++++++++ extensions/feishu/src/media.ts | 43 +++++++++++++++++++---- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 4060e6e2cbb..639805e0b94 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -165,6 +165,8 @@ const FeishuSharedConfigShape = { chunkMode: z.enum(["length", "newline"]).optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema, mediaMaxMb: z.number().positive().optional(), + /** Allowed roots for local media paths, or "any" to allow any path. Used by sendMedia/local file uploads. */ + localRoots: z.union([z.literal("any"), z.array(z.string())]).optional(), httpTimeoutMs: z.number().int().positive().max(300_000).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, renderMode: RenderModeSchema, diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 813e5090292..055ddd531c3 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -288,6 +288,53 @@ describe("sendMediaFeishu msg_type routing", () => { ); }); + it("uses channels.feishu.localRoots 'any' and passes readFile for local paths", async () => { + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("local-file"), + fileName: "pic.png", + kind: "image", + contentType: "image/png", + }); + + await sendMediaFeishu({ + cfg: { channels: { feishu: { localRoots: "any" } } } as any, + to: "user:ou_target", + mediaUrl: "/Users/zhengxing/openclaw/workspace/ask_official.png", + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith( + "/Users/zhengxing/openclaw/workspace/ask_official.png", + expect.objectContaining({ + localRoots: "any", + readFile: expect.any(Function), + }), + ); + }); + + it("uses channels.feishu.localRoots array over context mediaLocalRoots", async () => { + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("local-file"), + fileName: "doc.pdf", + kind: "document", + contentType: "application/pdf", + }); + + const channelRoots = ["/custom/feishu/root"]; + await sendMediaFeishu({ + cfg: { channels: { feishu: { localRoots: channelRoots } } } as any, + to: "user:ou_target", + mediaUrl: "/custom/feishu/root/file.pdf", + mediaLocalRoots: ["/other/context/root"], + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith( + "/custom/feishu/root/file.pdf", + expect.objectContaining({ + localRoots: channelRoots, + }), + ); + }); + it("fails closed when media URL fetch is blocked", async () => { loadWebMediaMock.mockRejectedValueOnce( new Error("Blocked: resolves to private/internal IP address"), diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 4aba038b4a9..c5046792f86 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -8,6 +8,7 @@ import { normalizeFeishuExternalKey } from "./external-keys.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; import { resolveFeishuSendTarget } from "./send-target.js"; +import type { FeishuConfig } from "./types.js"; const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000; @@ -413,10 +414,29 @@ export function detectFileType( } } +/** + * Resolve effective localRoots for loadWebMedia: channel config (localRoots) overrides + * context mediaLocalRoots. Supports channels.feishu.localRoots = "any" or string[]. + */ +function resolveFeishuMediaLocalRoots(params: { + cfg: ClawdbotConfig; + mediaLocalRoots?: readonly string[]; +}): readonly string[] | "any" | undefined { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const channelRoots = feishuCfg?.localRoots; + if (channelRoots === "any") { + return "any"; + } + if (Array.isArray(channelRoots) && channelRoots.length > 0) { + return channelRoots; + } + return params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined; +} + /** * Upload and send media (image or file) from URL, local path, or buffer. - * When mediaUrl is a local path, mediaLocalRoots (from core outbound context) - * must be passed so loadWebMedia allows the path (post CVE-2026-26321). + * When mediaUrl is a local path, allowed roots come from channels.feishu.localRoots + * (or "any") or from core outbound mediaLocalRoots. */ export async function sendMediaFeishu(params: { cfg: ClawdbotConfig; @@ -427,7 +447,7 @@ export async function sendMediaFeishu(params: { replyToMessageId?: string; replyInThread?: boolean; accountId?: string; - /** Allowed roots for local path reads; required for local filePath to work. */ + /** Allowed roots for local path reads (from core); overridden by channels.feishu.localRoots. */ mediaLocalRoots?: readonly string[]; }): Promise { const { @@ -454,11 +474,22 @@ export async function sendMediaFeishu(params: { buffer = mediaBuffer; name = fileName ?? "file"; } else if (mediaUrl) { - const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, { + const localRoots = resolveFeishuMediaLocalRoots({ cfg, mediaLocalRoots }); + const loadOptions: { + maxBytes: number; + optimizeImages: boolean; + localRoots: readonly string[] | "any" | undefined; + readFile?: (filePath: string) => Promise; + } = { maxBytes: mediaMaxBytes, optimizeImages: false, - localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, - }); + localRoots, + }; + // Core requires readFile override when localRoots is "any" (unsafe-bypass guard). + if (localRoots === "any") { + loadOptions.readFile = (filePath: string) => fs.promises.readFile(filePath); + } + const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, loadOptions); buffer = loaded.buffer; name = fileName ?? loaded.fileName ?? "file"; } else { From c07ef97d60c9d1e73cd93daca662aac145b88914 Mon Sep 17 00:00:00 2001 From: saurav470 Date: Sun, 8 Mar 2026 11:20:07 +0530 Subject: [PATCH 2/5] fix(feishu): update media handling to support merged localRoots configuration and enhance related tests --- extensions/feishu/src/media.test.ts | 14 ++++++++++++-- extensions/feishu/src/media.ts | 18 ++++++++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 055ddd531c3..906a4170772 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -295,15 +295,20 @@ describe("sendMediaFeishu msg_type routing", () => { kind: "image", contentType: "image/png", }); + resolveFeishuAccountMock.mockReturnValueOnce({ + configured: true, + accountId: "main", + config: { localRoots: "any" }, + }); await sendMediaFeishu({ cfg: { channels: { feishu: { localRoots: "any" } } } as any, to: "user:ou_target", - mediaUrl: "/Users/zhengxing/openclaw/workspace/ask_official.png", + mediaUrl: "/local/feishu/workspace/ask_official.png", }); expect(loadWebMediaMock).toHaveBeenCalledWith( - "/Users/zhengxing/openclaw/workspace/ask_official.png", + "/local/feishu/workspace/ask_official.png", expect.objectContaining({ localRoots: "any", readFile: expect.any(Function), @@ -320,6 +325,11 @@ describe("sendMediaFeishu msg_type routing", () => { }); const channelRoots = ["/custom/feishu/root"]; + resolveFeishuAccountMock.mockReturnValueOnce({ + configured: true, + accountId: "main", + config: { localRoots: channelRoots }, + }); await sendMediaFeishu({ cfg: { channels: { feishu: { localRoots: channelRoots } } } as any, to: "user:ou_target", diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index c5046792f86..db95a07a51e 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -415,15 +415,14 @@ export function detectFileType( } /** - * Resolve effective localRoots for loadWebMedia: channel config (localRoots) overrides - * context mediaLocalRoots. Supports channels.feishu.localRoots = "any" or string[]. + * Resolve effective localRoots for loadWebMedia from merged account config (so + * channels.feishu.accounts..localRoots overrides top-level). Supports "any" or string[]. */ function resolveFeishuMediaLocalRoots(params: { - cfg: ClawdbotConfig; + feishuConfig: FeishuConfig | undefined; mediaLocalRoots?: readonly string[]; }): readonly string[] | "any" | undefined { - const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; - const channelRoots = feishuCfg?.localRoots; + const channelRoots = params.feishuConfig?.localRoots; if (channelRoots === "any") { return "any"; } @@ -435,8 +434,8 @@ function resolveFeishuMediaLocalRoots(params: { /** * Upload and send media (image or file) from URL, local path, or buffer. - * When mediaUrl is a local path, allowed roots come from channels.feishu.localRoots - * (or "any") or from core outbound mediaLocalRoots. + * When mediaUrl is a local path, allowed roots come from merged Feishu config + * (channels.feishu.localRoots or channels.feishu.accounts..localRoots) or core mediaLocalRoots. */ export async function sendMediaFeishu(params: { cfg: ClawdbotConfig; @@ -474,7 +473,10 @@ export async function sendMediaFeishu(params: { buffer = mediaBuffer; name = fileName ?? "file"; } else if (mediaUrl) { - const localRoots = resolveFeishuMediaLocalRoots({ cfg, mediaLocalRoots }); + const localRoots = resolveFeishuMediaLocalRoots({ + feishuConfig: account.config, + mediaLocalRoots, + }); const loadOptions: { maxBytes: number; optimizeImages: boolean; From f89c2982f0503d0b2c1d0b5d27ce1bb7e640a377 Mon Sep 17 00:00:00 2001 From: saurav470 Date: Sun, 8 Mar 2026 13:38:43 +0530 Subject: [PATCH 3/5] fix(feishu): enhance media handling to respect explicit empty localRoots and update related tests --- extensions/feishu/src/media.test.ts | 28 ++++++++++++++++++++++++++++ extensions/feishu/src/media.ts | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 906a4170772..44a4ddb4e99 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -345,6 +345,34 @@ describe("sendMediaFeishu msg_type routing", () => { ); }); + it("honors explicit empty localRoots (no fallback to context mediaLocalRoots)", async () => { + resolveFeishuAccountMock.mockReturnValueOnce({ + configured: true, + accountId: "main", + config: { localRoots: [] }, + }); + loadWebMediaMock.mockRejectedValueOnce( + new (class extends Error { + code = "path-not-allowed"; + name = "LocalMediaAccessError"; + })(), + ); + + await expect( + sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaUrl: "/some/local/file.png", + mediaLocalRoots: ["/allowed/context/root"], + }), + ).rejects.toMatchObject({ code: "path-not-allowed" }); + + expect(loadWebMediaMock).toHaveBeenCalledWith( + "/some/local/file.png", + expect.objectContaining({ localRoots: [] }), + ); + }); + it("fails closed when media URL fetch is blocked", async () => { loadWebMediaMock.mockRejectedValueOnce( new Error("Blocked: resolves to private/internal IP address"), diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index db95a07a51e..c5bc09f006f 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -426,7 +426,8 @@ function resolveFeishuMediaLocalRoots(params: { if (channelRoots === "any") { return "any"; } - if (Array.isArray(channelRoots) && channelRoots.length > 0) { + // Honor explicit array (including empty): [] means disable local-path reads for Feishu. + if (Array.isArray(channelRoots)) { return channelRoots; } return params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined; From e70af87a1cb5b913bba1ab0c5931d58a9ac4447a Mon Sep 17 00:00:00 2001 From: saurav470 Date: Sun, 8 Mar 2026 13:45:27 +0530 Subject: [PATCH 4/5] docs(feishu): clarify localRoots configuration for media uploads with security considerations --- extensions/feishu/src/config-schema.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 639805e0b94..3bdc19f301f 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -165,7 +165,13 @@ const FeishuSharedConfigShape = { chunkMode: z.enum(["length", "newline"]).optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema, mediaMaxMb: z.number().positive().optional(), - /** Allowed roots for local media paths, or "any" to allow any path. Used by sendMedia/local file uploads. */ + /** + * Allowed roots for local media paths (sendMedia / local file uploads). Array of absolute + * directory paths, or "any" to allow any local path. + * Security: "any" bypasses path-containment checks and grants unrestricted filesystem read + * access to the process. Prefer explicit path arrays in production; use "any" only in + * trusted environments (e.g. dev or locked-down hosts). + */ localRoots: z.union([z.literal("any"), z.array(z.string())]).optional(), httpTimeoutMs: z.number().int().positive().max(300_000).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, From 585f6ecf94d96a257eb9a614063b8a8c2dfe1361 Mon Sep 17 00:00:00 2001 From: saurav470 Date: Sun, 8 Mar 2026 13:53:01 +0530 Subject: [PATCH 5/5] test(feishu): add verification to ensure readFile bypass is not injected for plain array of roots --- extensions/feishu/src/media.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 44a4ddb4e99..cf7083a0fb9 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -343,6 +343,9 @@ describe("sendMediaFeishu msg_type routing", () => { localRoots: channelRoots, }), ); + // readFile bypass must NOT be injected for a plain array of roots. + const callArg = loadWebMediaMock.mock.calls[0][1] as Record; + expect(callArg).not.toHaveProperty("readFile"); }); it("honors explicit empty localRoots (no fallback to context mediaLocalRoots)", async () => {