Root cause
Path & link handling
Initial release v0.1.0
How AI contributed: New attack surfacePublished Feb 17, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary The Feishu extension previously allowed sendMediaFeishu to treat attacker-controlled mediaUrl values as local filesystem paths and read them directly. 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 exfiltrate local files by supplying paths such as /etc/passwd as mediaUrl. Remediation Upgrade to OpenClaw 2026.2.14 or newer. Notes The fix removes direct local file reads from this path and routes media loading through hardened helpers that enforce local-root restrictions. --- Fix commit 5b4121d60 confirmed on main and in v2026.2.14. Upgrade to openclaw >= 2026.2.14.
The Feishu media helper resolved attacker-controlled mediaUrl values: tilde and file-style prefixes were expanded into local filesystem paths and read straight from disk, while every other address was fetched without restriction. A tool call reachable through prompt injection could therefore name a local path such as /etc/passwd and have it read and uploaded. The fix deletes the local-filesystem branch and routes every supplied address through a bounded runtime loader.
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