Root cause
Path & link handling introduced
feat: add inbound media support and card render mode
How AI contributed: Direct introductionPublished Mar 3, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary OpenClaw’s Feishu media download flow used untrusted Feishu media keys (imageKey / fileKey) when building temporary file paths in extensions/feishu/src/media.ts. Because those keys were interpolated directly into temp-file paths, traversal segments could escape the temp directory and redirect writes outside os.tmpdir(). Impact This is an arbitrary file write issue (within the OpenClaw process file permissions). If an attacker can control Feishu media key values returned to the client (for example via compromised upstream response path), they can influence where downloaded bytes are written. Affected Packages / Versions · Package: openclaw (npm) · Latest published npm version at triage: 2026.2.17 · Affected versions: <= 2026.2.17 · Fixed version: 2026.2.19 Fix Commit(s) · c821099157a9767d4df208c6b12f214946507871 · cdb00fe2428000e7a08f9b7848784a0049176705 · ec232a9e2dff60f0e3d7e827a7c868db5254473f Remediation The fix removes key-derived temp-file naming and keeps downloads in safe temp locations. Additional hardening isolates SDK writeFile calls in per-download temp directories (mkdtemp) with deterministic cleanup, enforces Feishu key trust-boundary validation, and adds a repository guard test against dynamic path.join(os.tmpdir(), \...${...}\) patterns in runtime code. OpenClaw thanks @allsmog for reporting.
An import commit with an AI trailer is not origin when the byte-identical temp-path templates first appear in an upstream atomic.
Root cause
feat: add inbound media support and card render mode
Fix
Feishu: harden temp media download paths
Case facts
+ const response = await client.im.image.get({+ path: { image_key: imageKey },+ });++ const responseAny = response as any;+ if (responseAny.code !== undefined && responseAny.code !== 0) {+ throw new Error(`Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`);+ }++ // Handle various response formats from Feishu SDK+ let buffer: Buffer;++ if (Buffer.isBuffer(response)) {+ buffer = response;+ } else if (response instanceof ArrayBuffer) {+ buffer = Buffer.from(response);+ } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {+ buffer = responseAny.data;+ } else if (responseAny.data instanceof ArrayBuffer) {+ buffer = Buffer.from(responseAny.data);+ } else if (typeof responseAny.getReadableStream === "function") {+ // SDK provides getReadableStream method+ const stream = responseAny.getReadableStream();+ const chunks: Buffer[] = [];+ for await (const chunk of stream) {+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));+ }+ buffer = Buffer.concat(chunks);+ } else if (typeof responseAny.writeFile === "function") {+ // SDK provides writeFile method - use a temp file+ const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);+ await responseAny.writeFile(tmpPath);+ buffer = await fs.promises.readFile(tmpPath);+ await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup+ } else if (typeof responseAny[Symbol.asyncIterator] === "function") {+ // Response is an async iterable+ const chunks: Buffer[] = [];+ for await (const chunk of responseAny) {+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));+ }+ buffer = Buffer.concat(chunks);+ } else if (typeof responseAny.read === "function") {+ // Response is a Readable stream+ const chunks: Buffer[] = [];+ for await (const chunk of responseAny as Readable) {+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));+ }+ buffer = Buffer.concat(chunks);+ } else {+ // Debug: log what we actually received+ const keys = Object.keys(responseAny);+ const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", ");+ throw new Error(+ `Feishu image download failed: unexpected response format. Keys: [${types}]`,+ );+ }++ return { buffer };+}++/**+ * Download a message resource (file/image/audio/video) from Feishu.+ * Used for downloading files, audio, and video from messages.+ */+export async function downloadMessageResourceFeishu(params: {+ cfg: ClawdbotConfig;+ messageId: string;+ fileKey: string;+ type: "image" | "file";+}): Promise<DownloadMessageResourceResult> {+ const { cfg, messageId, fileKey, type } = params;+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;+ if (!feishuCfg) {+ throw new Error("Feishu channel not configured");+ }++ const client = createFeishuClient(feishuCfg);++ const response = await client.im.messageResource.get({+ path: { message_id: messageId, file_key: fileKey },+ params: { type },+ });++ const responseAny = response as any;+ if (responseAny.code !== undefined && responseAny.code !== 0) {+ throw new Error(+ `Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`,+ );+ }+@@ -1,4 +1,5 @@ import fs from "fs";+import crypto from "node:crypto"; import os from "os"; import path from "path"; import { Readable } from "stream";@@ -99,7 +100,7 @@ export async function downloadImageFeishu(params: { path: { image_key: imageKey }, }); - const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);+ const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${crypto.randomUUID()}`); const buffer = await readFeishuResponseBuffer({ response, tmpPath,@@ -132,7 +133,7 @@ export async function downloadMessageResourceFeishu(params: { params: { type }, }); - const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);+ const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${crypto.randomUUID()}`); const buffer = await readFeishuResponseBuffer({ response, tmpPath,AI-assisted change f0887ee255adaf10e235f971783c81865d04472de4c91870456e25485c49f7e8 · Fix 5043e1af1af4b9881db8a7c25633c86b1beeb3602509e47fbaa87dbe9ad20962