VYPR
Moderate severityNVD Advisory· Published Mar 18, 2026· Updated Mar 18, 2026

OpenClaw < 2026.2.19 - Path Traversal in Feishu Media Temporary File Naming

CVE-2026-22171

Description

OpenClaw versions prior to 2026.2.19 contain a path traversal vulnerability in the Feishu media download flow where untrusted media keys are interpolated directly into temporary file paths in extensions/feishu/src/media.ts. An attacker who can control Feishu media key values returned to the client can use traversal segments to escape os.tmpdir() and write arbitrary files within the OpenClaw process permissions.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.192026.2.19

Affected products

1

Patches

3
ec232a9e2dff

refactor(security): harden temp-path handling for inbound media

https://github.com/openclaw/openclawPeter SteinbergerFeb 19, 2026via ghsa
10 files changed · +235 41
  • extensions/feishu/src/bot.ts+15 9 modified
    @@ -7,11 +7,14 @@ import {
       DEFAULT_GROUP_HISTORY_LIMIT,
       type HistoryEntry,
     } from "openclaw/plugin-sdk";
    +import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
    +import type { DynamicAgentCreationConfig } from "./types.js";
     import { resolveFeishuAccount } from "./accounts.js";
     import { createFeishuClient } from "./client.js";
     import { tryRecordMessage } from "./dedup.js";
     import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
    -import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
    +import { normalizeFeishuExternalKey } from "./external-keys.js";
    +import { downloadMessageResourceFeishu } from "./media.js";
     import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
     import {
       resolveFeishuGroupConfig,
    @@ -22,8 +25,6 @@ import {
     import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
     import { getFeishuRuntime } from "./runtime.js";
     import { getMessageFeishu, sendMessageFeishu } from "./send.js";
    -import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
    -import type { DynamicAgentCreationConfig } from "./types.js";
     
     // --- Permission error extraction ---
     // Extract permission grant URL from Feishu API error response.
    @@ -224,18 +225,20 @@ function parseMediaKeys(
     } {
       try {
         const parsed = JSON.parse(content);
    +    const imageKey = normalizeFeishuExternalKey(parsed.image_key);
    +    const fileKey = normalizeFeishuExternalKey(parsed.file_key);
         switch (messageType) {
           case "image":
    -        return { imageKey: parsed.image_key };
    +        return { imageKey };
           case "file":
    -        return { fileKey: parsed.file_key, fileName: parsed.file_name };
    +        return { fileKey, fileName: parsed.file_name };
           case "audio":
    -        return { fileKey: parsed.file_key };
    +        return { fileKey };
           case "video":
             // Video has both file_key (video) and image_key (thumbnail)
    -        return { fileKey: parsed.file_key, imageKey: parsed.image_key };
    +        return { fileKey, imageKey };
           case "sticker":
    -        return { fileKey: parsed.file_key };
    +        return { fileKey };
           default:
             return {};
         }
    @@ -277,7 +280,10 @@ function parsePostContent(content: string): {
                 }
               } else if (element.tag === "img" && element.image_key) {
                 // Embedded image
    -            imageKeys.push(element.image_key);
    +            const imageKey = normalizeFeishuExternalKey(element.image_key);
    +            if (imageKey) {
    +              imageKeys.push(imageKey);
    +            }
               }
             }
             textContent += "\n";
    
  • extensions/feishu/src/external-keys.test.ts+20 0 added
    @@ -0,0 +1,20 @@
    +import { describe, expect, it } from "vitest";
    +import { normalizeFeishuExternalKey } from "./external-keys.js";
    +
    +describe("normalizeFeishuExternalKey", () => {
    +  it("accepts a normal feishu key and trims surrounding spaces", () => {
    +    expect(normalizeFeishuExternalKey("  img_v3_01abcDEF123  ")).toBe("img_v3_01abcDEF123");
    +  });
    +
    +  it("rejects traversal and path separator patterns", () => {
    +    expect(normalizeFeishuExternalKey("../etc/passwd")).toBeUndefined();
    +    expect(normalizeFeishuExternalKey("a/../../b")).toBeUndefined();
    +    expect(normalizeFeishuExternalKey("a\\..\\b")).toBeUndefined();
    +  });
    +
    +  it("rejects empty, non-string, and control-char values", () => {
    +    expect(normalizeFeishuExternalKey("   ")).toBeUndefined();
    +    expect(normalizeFeishuExternalKey(123)).toBeUndefined();
    +    expect(normalizeFeishuExternalKey("abc\u0000def")).toBeUndefined();
    +  });
    +});
    
  • extensions/feishu/src/external-keys.ts+19 0 added
    @@ -0,0 +1,19 @@
    +const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f]/;
    +const MAX_EXTERNAL_KEY_LENGTH = 512;
    +
    +export function normalizeFeishuExternalKey(value: unknown): string | undefined {
    +  if (typeof value !== "string") {
    +    return undefined;
    +  }
    +  const normalized = value.trim();
    +  if (!normalized || normalized.length > MAX_EXTERNAL_KEY_LENGTH) {
    +    return undefined;
    +  }
    +  if (CONTROL_CHARS_RE.test(normalized)) {
    +    return undefined;
    +  }
    +  if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("..")) {
    +    return undefined;
    +  }
    +  return normalized;
    +}
    
  • extensions/feishu/src/media.test.ts+32 8 modified
    @@ -199,8 +199,8 @@ describe("sendMediaFeishu msg_type routing", () => {
         expect(messageReplyMock).not.toHaveBeenCalled();
       });
     
    -  it("does not include imageKey path segments in temp file path", async () => {
    -    const maliciousImageKey = "a/../../../../pwned.txt";
    +  it("uses isolated temp paths for image downloads", async () => {
    +    const imageKey = "img_v3_01abc123";
         let capturedPath: string | undefined;
     
         imageGetMock.mockResolvedValueOnce({
    @@ -212,12 +212,12 @@ describe("sendMediaFeishu msg_type routing", () => {
     
         const result = await downloadImageFeishu({
           cfg: {} as any,
    -      imageKey: maliciousImageKey,
    +      imageKey,
         });
     
         expect(result.buffer).toEqual(Buffer.from("image-data"));
         expect(capturedPath).toBeDefined();
    -    expect(capturedPath).not.toContain(maliciousImageKey);
    +    expect(capturedPath).not.toContain(imageKey);
         expect(capturedPath).not.toContain("..");
     
         const tmpRoot = path.resolve(os.tmpdir());
    @@ -226,8 +226,8 @@ describe("sendMediaFeishu msg_type routing", () => {
         expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
       });
     
    -  it("does not include fileKey path segments in temp file path", async () => {
    -    const maliciousFileKey = "x/../../../../../etc/hosts";
    +  it("uses isolated temp paths for message resource downloads", async () => {
    +    const fileKey = "file_v3_01abc123";
         let capturedPath: string | undefined;
     
         messageResourceGetMock.mockResolvedValueOnce({
    @@ -240,18 +240,42 @@ describe("sendMediaFeishu msg_type routing", () => {
         const result = await downloadMessageResourceFeishu({
           cfg: {} as any,
           messageId: "om_123",
    -      fileKey: maliciousFileKey,
    +      fileKey,
           type: "image",
         });
     
         expect(result.buffer).toEqual(Buffer.from("resource-data"));
         expect(capturedPath).toBeDefined();
    -    expect(capturedPath).not.toContain(maliciousFileKey);
    +    expect(capturedPath).not.toContain(fileKey);
         expect(capturedPath).not.toContain("..");
     
         const tmpRoot = path.resolve(os.tmpdir());
         const resolved = path.resolve(capturedPath as string);
         const rel = path.relative(tmpRoot, resolved);
         expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
       });
    +
    +  it("rejects invalid image keys before calling feishu api", async () => {
    +    await expect(
    +      downloadImageFeishu({
    +        cfg: {} as any,
    +        imageKey: "a/../../bad",
    +      }),
    +    ).rejects.toThrow("invalid image_key");
    +
    +    expect(imageGetMock).not.toHaveBeenCalled();
    +  });
    +
    +  it("rejects invalid file keys before calling feishu api", async () => {
    +    await expect(
    +      downloadMessageResourceFeishu({
    +        cfg: {} as any,
    +        messageId: "om_123",
    +        fileKey: "x/../../bad",
    +        type: "file",
    +      }),
    +    ).rejects.toThrow("invalid file_key");
    +
    +    expect(messageResourceGetMock).not.toHaveBeenCalled();
    +  });
     });
    
  • extensions/feishu/src/media.ts+13 18 modified
    @@ -1,10 +1,10 @@
     import fs from "fs";
    -import os from "os";
    +import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk";
     import path from "path";
     import { Readable } from "stream";
    -import type { ClawdbotConfig } from "openclaw/plugin-sdk";
     import { resolveFeishuAccount } from "./accounts.js";
     import { createFeishuClient } from "./client.js";
    +import { normalizeFeishuExternalKey } from "./external-keys.js";
     import { getFeishuRuntime } from "./runtime.js";
     import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
     import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
    @@ -20,19 +20,6 @@ export type DownloadMessageResourceResult = {
       fileName?: string;
     };
     
    -async function withTempDownloadPath<T>(
    -  prefix: string,
    -  fn: (tmpPath: string) => Promise<T>,
    -): Promise<T> {
    -  const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix));
    -  const tmpPath = path.join(dir, "download.bin");
    -  try {
    -    return await fn(tmpPath);
    -  } finally {
    -    await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
    -  }
    -}
    -
     async function readFeishuResponseBuffer(params: {
       response: unknown;
       tmpDirPrefix: string;
    @@ -66,7 +53,7 @@ async function readFeishuResponseBuffer(params: {
         return Buffer.concat(chunks);
       }
       if (typeof responseAny.writeFile === "function") {
    -    return await withTempDownloadPath(params.tmpDirPrefix, async (tmpPath) => {
    +    return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
           await responseAny.writeFile(tmpPath);
           return await fs.promises.readFile(tmpPath);
         });
    @@ -101,6 +88,10 @@ export async function downloadImageFeishu(params: {
       accountId?: string;
     }): Promise<DownloadImageResult> {
       const { cfg, imageKey, accountId } = params;
    +  const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
    +  if (!normalizedImageKey) {
    +    throw new Error("Feishu image download failed: invalid image_key");
    +  }
       const account = resolveFeishuAccount({ cfg, accountId });
       if (!account.configured) {
         throw new Error(`Feishu account "${account.accountId}" not configured`);
    @@ -109,7 +100,7 @@ export async function downloadImageFeishu(params: {
       const client = createFeishuClient(account);
     
       const response = await client.im.image.get({
    -    path: { image_key: imageKey },
    +    path: { image_key: normalizedImageKey },
       });
     
       const buffer = await readFeishuResponseBuffer({
    @@ -132,6 +123,10 @@ export async function downloadMessageResourceFeishu(params: {
       accountId?: string;
     }): Promise<DownloadMessageResourceResult> {
       const { cfg, messageId, fileKey, type, accountId } = params;
    +  const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
    +  if (!normalizedFileKey) {
    +    throw new Error("Feishu message resource download failed: invalid file_key");
    +  }
       const account = resolveFeishuAccount({ cfg, accountId });
       if (!account.configured) {
         throw new Error(`Feishu account "${account.accountId}" not configured`);
    @@ -140,7 +135,7 @@ export async function downloadMessageResourceFeishu(params: {
       const client = createFeishuClient(account);
     
       const response = await client.im.messageResource.get({
    -    path: { message_id: messageId, file_key: fileKey },
    +    path: { message_id: messageId, file_key: normalizedFileKey },
         params: { type },
       });
     
    
  • src/media-understanding/attachments.ts+6 4 modified
    @@ -1,17 +1,16 @@
    -import crypto from "node:crypto";
     import fs from "node:fs/promises";
    -import os from "node:os";
     import path from "node:path";
     import { fileURLToPath } from "node:url";
     import type { MsgContext } from "../auto-reply/templating.js";
     import type { MediaUnderstandingAttachmentsConfig } from "../config/types.tools.js";
    +import type { MediaAttachment, MediaUnderstandingCapability } from "./types.js";
     import { logVerbose, shouldLogVerbose } from "../globals.js";
     import { isAbortError } from "../infra/unhandled-rejections.js";
     import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js";
     import { detectMime, getFileExtension, isAudioFileName, kindFromMime } from "../media/mime.js";
    +import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js";
     import { MediaUnderstandingSkipError } from "./errors.js";
     import { fetchWithTimeout } from "./providers/shared.js";
    -import type { MediaAttachment, MediaUnderstandingCapability } from "./types.js";
     
     type MediaBufferResult = {
       buffer: Buffer;
    @@ -352,7 +351,10 @@ export class MediaAttachmentCache {
           timeoutMs: params.timeoutMs,
         });
         const extension = path.extname(bufferResult.fileName || "") || "";
    -    const tmpPath = path.join(os.tmpdir(), `openclaw-media-${crypto.randomUUID()}${extension}`);
    +    const tmpPath = buildRandomTempFilePath({
    +      prefix: "openclaw-media",
    +      extension,
    +    });
         await fs.writeFile(tmpPath, bufferResult.buffer);
         entry.tempPath = tmpPath;
         entry.tempCleanup = async () => {
    
  • src/plugin-sdk/index.ts+1 1 modified
    @@ -154,7 +154,7 @@ export { extractToolSend } from "./tool-send.js";
     export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
     export { chunkTextForOutbound } from "./text-chunking.js";
     export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
    -export { buildRandomTempFilePath } from "./temp-path.js";
    +export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
     export type { ChatType } from "../channels/chat-type.js";
     /** @deprecated Use ChatType instead */
     export type { RoutePeerKind } from "../routing/resolve-route.js";
    
  • src/plugin-sdk/temp-path.test.ts+40 1 modified
    @@ -1,7 +1,8 @@
    +import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
     import { describe, expect, it } from "vitest";
    -import { buildRandomTempFilePath } from "./temp-path.js";
    +import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
     
     describe("buildRandomTempFilePath", () => {
       it("builds deterministic paths when now/uuid are provided", () => {
    @@ -30,3 +31,41 @@ describe("buildRandomTempFilePath", () => {
         expect(result).not.toContain("..");
       });
     });
    +
    +describe("withTempDownloadPath", () => {
    +  it("creates a temp path under tmp dir and cleans up the temp directory", async () => {
    +    let capturedPath = "";
    +    await withTempDownloadPath(
    +      {
    +        prefix: "line-media",
    +      },
    +      async (tmpPath) => {
    +        capturedPath = tmpPath;
    +        await fs.writeFile(tmpPath, "ok");
    +      },
    +    );
    +
    +    expect(capturedPath).toContain(path.join(os.tmpdir(), "line-media-"));
    +    await expect(fs.stat(capturedPath)).rejects.toMatchObject({ code: "ENOENT" });
    +  });
    +
    +  it("sanitizes prefix and fileName", async () => {
    +    let capturedPath = "";
    +    await withTempDownloadPath(
    +      {
    +        prefix: "../../line/../media",
    +        fileName: "../../evil.bin",
    +      },
    +      async (tmpPath) => {
    +        capturedPath = tmpPath;
    +      },
    +    );
    +
    +    const tmpRoot = path.resolve(os.tmpdir());
    +    const resolved = path.resolve(capturedPath);
    +    const rel = path.relative(tmpRoot, resolved);
    +    expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
    +    expect(path.basename(capturedPath)).toBe("evil.bin");
    +    expect(capturedPath).not.toContain("..");
    +  });
    +});
    
  • src/plugin-sdk/temp-path.ts+26 0 modified
    @@ -1,4 +1,5 @@
     import crypto from "node:crypto";
    +import { mkdtemp, rm } from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
     
    @@ -20,6 +21,12 @@ function sanitizeExtension(extension?: string): string {
       return `.${token}`;
     }
     
    +function sanitizeFileName(fileName: string): string {
    +  const base = path.basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, "-");
    +  const normalized = base.replace(/^-+|-+$/g, "");
    +  return normalized || "download.bin";
    +}
    +
     export function buildRandomTempFilePath(params: {
       prefix: string;
       extension?: string;
    @@ -37,3 +44,22 @@ export function buildRandomTempFilePath(params: {
       const uuid = params.uuid?.trim() || crypto.randomUUID();
       return path.join(params.tmpDir ?? os.tmpdir(), `${prefix}-${now}-${uuid}${extension}`);
     }
    +
    +export async function withTempDownloadPath<T>(
    +  params: {
    +    prefix: string;
    +    fileName?: string;
    +    tmpDir?: string;
    +  },
    +  fn: (tmpPath: string) => Promise<T>,
    +): Promise<T> {
    +  const tempRoot = params.tmpDir ?? os.tmpdir();
    +  const prefix = `${sanitizePrefix(params.prefix)}-`;
    +  const dir = await mkdtemp(path.join(tempRoot, prefix));
    +  const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
    +  try {
    +    return await fn(tmpPath);
    +  } finally {
    +    await rm(dir, { recursive: true, force: true }).catch(() => {});
    +  }
    +}
    
  • src/security/temp-path-guard.test.ts+63 0 added
    @@ -0,0 +1,63 @@
    +import fs from "node:fs/promises";
    +import path from "node:path";
    +import { describe, expect, it } from "vitest";
    +
    +const DYNAMIC_TMPDIR_JOIN_RE = /path\.join\(os\.tmpdir\(\),\s*`[^`]*\$\{[^`]*`/;
    +const RUNTIME_ROOTS = ["src", "extensions"];
    +const SKIP_PATTERNS = [
    +  /\.test\.tsx?$/,
    +  /\.e2e\.tsx?$/,
    +  /\.d\.ts$/,
    +  /[\\/](?:__tests__|tests)[\\/]/,
    +  /[\\/]test-helpers(?:\.[^\\/]+)?\.ts$/,
    +];
    +
    +function shouldSkip(relativePath: string): boolean {
    +  return SKIP_PATTERNS.some((pattern) => pattern.test(relativePath));
    +}
    +
    +async function listTsFiles(dir: string): Promise<string[]> {
    +  const entries = await fs.readdir(dir, { withFileTypes: true });
    +  const out: string[] = [];
    +  for (const entry of entries) {
    +    if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
    +      continue;
    +    }
    +    const fullPath = path.join(dir, entry.name);
    +    if (entry.isDirectory()) {
    +      out.push(...(await listTsFiles(fullPath)));
    +      continue;
    +    }
    +    if (!entry.isFile()) {
    +      continue;
    +    }
    +    if (fullPath.endsWith(".ts") || fullPath.endsWith(".tsx")) {
    +      out.push(fullPath);
    +    }
    +  }
    +  return out;
    +}
    +
    +describe("temp path guard", () => {
    +  it("blocks dynamic template path.join(os.tmpdir(), ...) in runtime source files", async () => {
    +    const repoRoot = process.cwd();
    +    const offenders: string[] = [];
    +
    +    for (const root of RUNTIME_ROOTS) {
    +      const absRoot = path.join(repoRoot, root);
    +      const files = await listTsFiles(absRoot);
    +      for (const file of files) {
    +        const relativePath = path.relative(repoRoot, file);
    +        if (shouldSkip(relativePath)) {
    +          continue;
    +        }
    +        const source = await fs.readFile(file, "utf-8");
    +        if (DYNAMIC_TMPDIR_JOIN_RE.test(source)) {
    +          offenders.push(relativePath);
    +        }
    +      }
    +    }
    +
    +    expect(offenders).toEqual([]);
    +  });
    +});
    
cdb00fe24280

fix(feishu): isolate temp download writes in mkdtemp dirs

https://github.com/openclaw/openclawPeter SteinbergerFeb 19, 2026via ghsa
1 file changed · +22 10
  • extensions/feishu/src/media.ts+22 10 modified
    @@ -1,7 +1,8 @@
     import fs from "fs";
    +import os from "os";
     import path from "path";
     import { Readable } from "stream";
    -import { buildRandomTempFilePath, type ClawdbotConfig } from "openclaw/plugin-sdk";
    +import type { ClawdbotConfig } from "openclaw/plugin-sdk";
     import { resolveFeishuAccount } from "./accounts.js";
     import { createFeishuClient } from "./client.js";
     import { getFeishuRuntime } from "./runtime.js";
    @@ -19,9 +20,22 @@ export type DownloadMessageResourceResult = {
       fileName?: string;
     };
     
    +async function withTempDownloadPath<T>(
    +  prefix: string,
    +  fn: (tmpPath: string) => Promise<T>,
    +): Promise<T> {
    +  const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix));
    +  const tmpPath = path.join(dir, "download.bin");
    +  try {
    +    return await fn(tmpPath);
    +  } finally {
    +    await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
    +  }
    +}
    +
     async function readFeishuResponseBuffer(params: {
       response: unknown;
    -  tmpPath: string;
    +  tmpDirPrefix: string;
       errorPrefix: string;
     }): Promise<Buffer> {
       const { response } = params;
    @@ -52,10 +66,10 @@ async function readFeishuResponseBuffer(params: {
         return Buffer.concat(chunks);
       }
       if (typeof responseAny.writeFile === "function") {
    -    await responseAny.writeFile(params.tmpPath);
    -    const buffer = await fs.promises.readFile(params.tmpPath);
    -    await fs.promises.unlink(params.tmpPath).catch(() => {});
    -    return buffer;
    +    return await withTempDownloadPath(params.tmpDirPrefix, async (tmpPath) => {
    +      await responseAny.writeFile(tmpPath);
    +      return await fs.promises.readFile(tmpPath);
    +    });
       }
       if (typeof responseAny[Symbol.asyncIterator] === "function") {
         const chunks: Buffer[] = [];
    @@ -98,10 +112,9 @@ export async function downloadImageFeishu(params: {
         path: { image_key: imageKey },
       });
     
    -  const tmpPath = buildRandomTempFilePath({ prefix: "feishu_img" });
       const buffer = await readFeishuResponseBuffer({
         response,
    -    tmpPath,
    +    tmpDirPrefix: "openclaw-feishu-img-",
         errorPrefix: "Feishu image download failed",
       });
       return { buffer };
    @@ -131,10 +144,9 @@ export async function downloadMessageResourceFeishu(params: {
         params: { type },
       });
     
    -  const tmpPath = buildRandomTempFilePath({ prefix: "feishu" });
       const buffer = await readFeishuResponseBuffer({
         response,
    -    tmpPath,
    +    tmpDirPrefix: "openclaw-feishu-resource-",
         errorPrefix: "Feishu message resource download failed",
       });
       return { buffer };
    
c821099157a9

Feishu: harden temp media download paths

https://github.com/openclaw/openclawMariano BelinkyFeb 19, 2026via ghsa
2 files changed · +74 3
  • extensions/feishu/src/media.test.ts+71 1 modified
    @@ -1,3 +1,6 @@
    +import fs from "node:fs/promises";
    +import os from "node:os";
    +import path from "node:path";
     import { beforeEach, describe, expect, it, vi } from "vitest";
     
     const createFeishuClientMock = vi.hoisted(() => vi.fn());
    @@ -7,7 +10,9 @@ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
     const loadWebMediaMock = vi.hoisted(() => vi.fn());
     
     const fileCreateMock = vi.hoisted(() => vi.fn());
    +const imageGetMock = vi.hoisted(() => vi.fn());
     const messageCreateMock = vi.hoisted(() => vi.fn());
    +const messageResourceGetMock = vi.hoisted(() => vi.fn());
     const messageReplyMock = vi.hoisted(() => vi.fn());
     
     vi.mock("./client.js", () => ({
    @@ -31,7 +36,7 @@ vi.mock("./runtime.js", () => ({
       }),
     }));
     
    -import { sendMediaFeishu } from "./media.js";
    +import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
     
     describe("sendMediaFeishu msg_type routing", () => {
       beforeEach(() => {
    @@ -54,10 +59,16 @@ describe("sendMediaFeishu msg_type routing", () => {
             file: {
               create: fileCreateMock,
             },
    +        image: {
    +          get: imageGetMock,
    +        },
             message: {
               create: messageCreateMock,
               reply: messageReplyMock,
             },
    +        messageResource: {
    +          get: messageResourceGetMock,
    +        },
           },
         });
     
    @@ -82,6 +93,9 @@ describe("sendMediaFeishu msg_type routing", () => {
           kind: "audio",
           contentType: "audio/ogg",
         });
    +
    +    imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
    +    messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
       });
     
       it("uses msg_type=media for mp4", async () => {
    @@ -184,4 +198,60 @@ describe("sendMediaFeishu msg_type routing", () => {
         expect(messageCreateMock).not.toHaveBeenCalled();
         expect(messageReplyMock).not.toHaveBeenCalled();
       });
    +
    +  it("does not include imageKey path segments in temp file path", async () => {
    +    const maliciousImageKey = "a/../../../../pwned.txt";
    +    let capturedPath: string | undefined;
    +
    +    imageGetMock.mockResolvedValueOnce({
    +      writeFile: async (tmpPath: string) => {
    +        capturedPath = tmpPath;
    +        await fs.writeFile(tmpPath, Buffer.from("image-data"));
    +      },
    +    });
    +
    +    const result = await downloadImageFeishu({
    +      cfg: {} as any,
    +      imageKey: maliciousImageKey,
    +    });
    +
    +    expect(result.buffer).toEqual(Buffer.from("image-data"));
    +    expect(capturedPath).toBeDefined();
    +    expect(capturedPath).not.toContain(maliciousImageKey);
    +    expect(capturedPath).not.toContain("..");
    +
    +    const tmpRoot = path.resolve(os.tmpdir());
    +    const resolved = path.resolve(capturedPath as string);
    +    const rel = path.relative(tmpRoot, resolved);
    +    expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
    +  });
    +
    +  it("does not include fileKey path segments in temp file path", async () => {
    +    const maliciousFileKey = "x/../../../../../etc/hosts";
    +    let capturedPath: string | undefined;
    +
    +    messageResourceGetMock.mockResolvedValueOnce({
    +      writeFile: async (tmpPath: string) => {
    +        capturedPath = tmpPath;
    +        await fs.writeFile(tmpPath, Buffer.from("resource-data"));
    +      },
    +    });
    +
    +    const result = await downloadMessageResourceFeishu({
    +      cfg: {} as any,
    +      messageId: "om_123",
    +      fileKey: maliciousFileKey,
    +      type: "image",
    +    });
    +
    +    expect(result.buffer).toEqual(Buffer.from("resource-data"));
    +    expect(capturedPath).toBeDefined();
    +    expect(capturedPath).not.toContain(maliciousFileKey);
    +    expect(capturedPath).not.toContain("..");
    +
    +    const tmpRoot = path.resolve(os.tmpdir());
    +    const resolved = path.resolve(capturedPath as string);
    +    const rel = path.relative(tmpRoot, resolved);
    +    expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
    +  });
     });
    
  • extensions/feishu/src/media.ts+3 2 modified
    @@ -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,
    

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

News mentions

0

No linked articles in our index yet.