Root cause
SSRF & network boundaries introduced
Initial release v0.1.0
How AI contributed: Direct introductionPublished Feb 18, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary The Feishu extension could fetch attacker-controlled remote URLs in two paths without SSRF protections: · sendMediaFeishu(mediaUrl) · Feishu DocX markdown image URLs (write/append -> image processing) Affected versions · < 2026.2.14 Patched versions · >= 2026.2.14 Impact If an attacker can influence tool calls (directly or via prompt injection), they may be able to trigger requests to internal services and re-upload the response as Feishu media. Remediation Upgrade to OpenClaw 2026.2.14 or newer. Notes The fix routes Feishu remote media fetching through hardened runtime helpers that enforce SSRF policies and size limits.
The same Feishu harden-fetch commit can close remote SSRF here while ordinal 31 is the local-path branch of the same media helper.
Root cause
Initial release v0.1.0
Fix
fix: harden Feishu media URL fetching (#16285) (thanks @mbelinky)
Case facts
+export async function sendMediaFeishu(params: {+ cfg: ClawdbotConfig;+ to: string;+ mediaUrl?: string;+ mediaBuffer?: Buffer;+ fileName?: string;+ replyToMessageId?: string;+}): Promise<SendMediaResult> {+ const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId } = params;++ let buffer: Buffer;+ let name: string;++ if (mediaBuffer) {+ 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");+ }+ } else {+ throw new Error("Either mediaUrl or mediaBuffer must be provided");+ }++ // Determine if it's an image based on extension+ const ext = path.extname(name).toLowerCase();+ const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);++ if (isImage) {+ const { imageKey } = await uploadImageFeishu({ cfg, image: buffer });+ return sendImageFeishu({ cfg, to, imageKey, replyToMessageId });+ } else {+ const fileType = detectFileType(name);+ const { fileKey } = await uploadFileFeishu({+ cfg,+ file: buffer,+ fileName: name,+ fileType,+ });+ return sendFileFeishu({ cfg, to, fileKey, replyToMessageId });+ }+}@@ -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"); }AI-assisted change 9a7784a166c56fb02102113a2dbbaabf7652533620053e8c37d52e40ac47853d · Fix 9d090de8d286d344b0eb6e7d1dc3bdd4b0bd8b4bf32e439716b397ba0f4c6973