High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026
OpenClaw < 2026.2.14 - SSRF via Feishu Extension Media Fetching
CVE-2026-28451
Description
OpenClaw versions prior to 2026.2.14 contain server-side request forgery vulnerabilities in the Feishu extension that allow attackers to fetch attacker-controlled remote URLs without SSRF protections via sendMediaFeishu function and markdown image processing. Attackers can influence tool calls through direct manipulation or prompt injection to trigger requests to internal services and re-upload responses as Feishu media.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.14 | 2026.2.14 |
Affected products
1Patches
15b4121d6011afix: harden Feishu media URL fetching (#16285) (thanks @mbelinky)
5 files changed · +190 −50
CHANGELOG.md+1 −0 modified@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky. - Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals) and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc. - Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson. - Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
extensions/feishu/src/docx.test.ts+123 −0 added@@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const fetchRemoteMediaMock = vi.hoisted(() => vi.fn()); + +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + +vi.mock("./runtime.js", () => ({ + getFeishuRuntime: () => ({ + channel: { + media: { + fetchRemoteMedia: fetchRemoteMediaMock, + }, + }, + }), +})); + +import { registerFeishuDocTools } from "./docx.js"; + +describe("feishu_doc image fetch hardening", () => { + const convertMock = vi.hoisted(() => vi.fn()); + const blockListMock = vi.hoisted(() => vi.fn()); + const blockChildrenCreateMock = vi.hoisted(() => vi.fn()); + const driveUploadAllMock = vi.hoisted(() => vi.fn()); + const blockPatchMock = vi.hoisted(() => vi.fn()); + const scopeListMock = vi.hoisted(() => vi.fn()); + + beforeEach(() => { + vi.clearAllMocks(); + + createFeishuClientMock.mockReturnValue({ + docx: { + document: { + convert: convertMock, + }, + documentBlock: { + list: blockListMock, + patch: blockPatchMock, + }, + documentBlockChildren: { + create: blockChildrenCreateMock, + }, + }, + drive: { + media: { + uploadAll: driveUploadAllMock, + }, + }, + application: { + scope: { + list: scopeListMock, + }, + }, + }); + + convertMock.mockResolvedValue({ + code: 0, + data: { + blocks: [{ block_type: 27 }], + first_level_block_ids: [], + }, + }); + + blockListMock.mockResolvedValue({ + code: 0, + data: { + items: [], + }, + }); + + blockChildrenCreateMock.mockResolvedValue({ + code: 0, + data: { + children: [{ block_type: 27, block_id: "img_block_1" }], + }, + }); + + driveUploadAllMock.mockResolvedValue({ file_token: "token_1" }); + blockPatchMock.mockResolvedValue({ code: 0 }); + scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } }); + }); + + it("skips image upload when markdown image URL is blocked", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + fetchRemoteMediaMock.mockRejectedValueOnce( + new Error("Blocked: resolves to private/internal IP address"), + ); + + const registerTool = vi.fn(); + registerFeishuDocTools({ + config: { + channels: { + feishu: { + appId: "app_id", + appSecret: "app_secret", + }, + }, + } as any, + logger: { debug: vi.fn(), info: vi.fn() } as any, + registerTool, + } as any); + + const feishuDocTool = registerTool.mock.calls + .map((call) => call[0]) + .find((tool) => tool.name === "feishu_doc"); + expect(feishuDocTool).toBeDefined(); + + const result = await feishuDocTool.execute("tool-call", { + action: "write", + doc_token: "doc_1", + content: "", + }); + + expect(fetchRemoteMediaMock).toHaveBeenCalled(); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + expect(blockPatchMock).not.toHaveBeenCalled(); + expect(result.details.images_processed).toBe(0); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); +});
extensions/feishu/src/docx.ts+18 −13 modified@@ -5,6 +5,7 @@ import { Readable } from "stream"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; +import { getFeishuRuntime } from "./runtime.js"; import { resolveToolsConfig } from "./tools-config.js"; // ============ Helpers ============ @@ -175,12 +176,9 @@ async function uploadImageToDocx( return fileToken; } -async function downloadImage(url: string): Promise<Buffer> { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to download image: ${response.status} ${response.statusText}`); - } - return Buffer.from(await response.arrayBuffer()); +async function downloadImage(url: string, maxBytes: number): Promise<Buffer> { + const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes }); + return fetched.buffer; } /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */ @@ -189,6 +187,7 @@ async function processImages( docToken: string, markdown: string, insertedBlocks: any[], + maxBytes: number, ): Promise<number> { /* eslint-enable @typescript-eslint/no-explicit-any */ const imageUrls = extractImageUrls(markdown); @@ -204,7 +203,7 @@ async function processImages( const blockId = imageBlocks[i].block_id; try { - const buffer = await downloadImage(url); + const buffer = await downloadImage(url, maxBytes); const urlPath = new URL(url).pathname; const fileName = urlPath.split("/").pop() || `image_${i}.png`; const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName); @@ -284,7 +283,7 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin }; } -async function writeDoc(client: Lark.Client, docToken: string, markdown: string) { +async function writeDoc(client: Lark.Client, docToken: string, markdown: string, maxBytes: number) { const deleted = await clearDocumentContent(client, docToken); const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown); @@ -294,7 +293,7 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string) const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds); const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks); - const imagesProcessed = await processImages(client, docToken, markdown, inserted); + const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); return { success: true, @@ -307,15 +306,20 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string) }; } -async function appendDoc(client: Lark.Client, docToken: string, markdown: string) { +async function appendDoc( + client: Lark.Client, + docToken: string, + markdown: string, + maxBytes: number, +) { const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown); if (blocks.length === 0) { throw new Error("Content is empty"); } const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds); const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks); - const imagesProcessed = await processImages(client, docToken, markdown, inserted); + const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); return { success: true, @@ -453,6 +457,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { // Use first account's config for tools configuration const firstAccount = accounts[0]; const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const mediaMaxBytes = (firstAccount.config?.mediaMaxMb ?? 30) * 1024 * 1024; // Helper to get client for the default account const getClient = () => createFeishuClient(firstAccount); @@ -475,9 +480,9 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { case "read": return json(await readDoc(client, p.doc_token)); case "write": - return json(await writeDoc(client, p.doc_token, p.content)); + return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes)); case "append": - return json(await appendDoc(client, p.doc_token, p.content)); + return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes)); case "create": return json(await createDoc(client, p.title, p.folder_token)); case "list_blocks":
extensions/feishu/src/media.test.ts+36 −0 modified@@ -4,6 +4,7 @@ const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); +const loadWebMediaMock = vi.hoisted(() => vi.fn()); const fileCreateMock = vi.hoisted(() => vi.fn()); const messageCreateMock = vi.hoisted(() => vi.fn()); @@ -22,6 +23,14 @@ vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock, })); +vi.mock("./runtime.js", () => ({ + getFeishuRuntime: () => ({ + media: { + loadWebMedia: loadWebMediaMock, + }, + }), +})); + import { sendMediaFeishu } from "./media.js"; describe("sendMediaFeishu msg_type routing", () => { @@ -31,6 +40,7 @@ describe("sendMediaFeishu msg_type routing", () => { resolveFeishuAccountMock.mockReturnValue({ configured: true, accountId: "main", + config: {}, appId: "app_id", appSecret: "app_secret", domain: "feishu", @@ -65,6 +75,13 @@ describe("sendMediaFeishu msg_type routing", () => { code: 0, data: { message_id: "reply_1" }, }); + + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("remote-audio"), + fileName: "remote.opus", + kind: "audio", + contentType: "audio/ogg", + }); }); it("uses msg_type=media for mp4", async () => { @@ -148,4 +165,23 @@ describe("sendMediaFeishu msg_type routing", () => { expect(messageCreateMock).not.toHaveBeenCalled(); }); + + it("fails closed when media URL fetch is blocked", async () => { + loadWebMediaMock.mockRejectedValueOnce( + new Error("Blocked: resolves to private/internal IP address"), + ); + + await expect( + sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaUrl: "https://x/img", + fileName: "voice.opus", + }), + ).rejects.toThrow(/private\/internal/i); + + expect(fileCreateMock).not.toHaveBeenCalled(); + expect(messageCreateMock).not.toHaveBeenCalled(); + expect(messageReplyMock).not.toHaveBeenCalled(); + }); });
extensions/feishu/src/media.ts+12 −37 modified@@ -5,6 +5,7 @@ import path from "path"; import { Readable } from "stream"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { getFeishuRuntime } from "./runtime.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; export type DownloadImageResult = { @@ -449,23 +450,6 @@ export function detectFileType( } } -/** - * Check if a string is a local file path (not a URL) - */ -function isLocalPath(urlOrPath: string): boolean { - // Starts with / or ~ or drive letter (Windows) - if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) { - return true; - } - // Try to parse as URL - if it fails or has no protocol, it's likely a local path - try { - const url = new URL(urlOrPath); - return url.protocol === "file:"; - } catch { - return true; // Not a valid URL, treat as local path - } -} - /** * Upload and send media (image or file) from URL, local path, or buffer */ @@ -479,6 +463,11 @@ export async function sendMediaFeishu(params: { accountId?: string; }): Promise<SendMediaResult> { const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024; let buffer: Buffer; let name: string; @@ -487,26 +476,12 @@ export async function sendMediaFeishu(params: { buffer = mediaBuffer; name = fileName ?? "file"; } else if (mediaUrl) { - if (isLocalPath(mediaUrl)) { - // Local file path - read directly - const filePath = mediaUrl.startsWith("~") - ? mediaUrl.replace("~", process.env.HOME ?? "") - : mediaUrl.replace("file://", ""); - - if (!fs.existsSync(filePath)) { - throw new Error(`Local file not found: ${filePath}`); - } - buffer = fs.readFileSync(filePath); - name = fileName ?? path.basename(filePath); - } else { - // Remote URL - fetch - const response = await fetch(mediaUrl); - if (!response.ok) { - throw new Error(`Failed to fetch media from URL: ${response.status}`); - } - buffer = Buffer.from(await response.arrayBuffer()); - name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file"); - } + const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, { + maxBytes: mediaMaxBytes, + optimizeImages: false, + }); + buffer = loaded.buffer; + name = fileName ?? loaded.fileName ?? "file"; } else { throw new Error("Either mediaUrl or mediaBuffer must be provided"); }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/openclaw/openclaw/commit/5b4121d6011a48c71e747e3c18197f180b872c5dghsapatchWEB
- github.com/advisories/GHSA-x22m-j5qq-j49mghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-x22m-j5qq-j49mghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28451ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-ssrf-via-feishu-extension-media-fetchingghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/pull/16285ghsaWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.14ghsaWEB
News mentions
0No linked articles in our index yet.