OpenClaw < 2026.2.19 - Path Traversal in Feishu Media Temporary File Naming
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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.19 | 2026.2.19 |
Affected products
1Patches
3ec232a9e2dffrefactor(security): harden temp-path handling for inbound media
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([]); + }); +});
cdb00fe24280fix(feishu): isolate temp download writes in mkdtemp dirs
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 };
c821099157a9Feishu: harden temp media download paths
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- github.com/openclaw/openclaw/commit/c821099157a9767d4df208c6b12f214946507871ghsapatchWEB
- github.com/openclaw/openclaw/commit/cdb00fe2428000e7a08f9b7848784a0049176705ghsapatchWEB
- github.com/openclaw/openclaw/commit/ec232a9e2dff60f0e3d7e827a7c868db5254473fghsapatchWEB
- github.com/advisories/GHSA-vj3g-5px3-gr46ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-vj3g-5px3-gr46ghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-22171ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-path-traversal-in-feishu-media-temporary-file-namingghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.