Medium severity5.3NVD Advisory· Published Apr 28, 2026· Updated Apr 28, 2026
CVE-2026-41363
CVE-2026-41363
Description
OpenClaw versions 2026.2.6 through 2026.3.24 contain a path traversal vulnerability in the Feishu extension resolveUploadInput function that bypasses file-system sandbox restrictions. Attackers can exploit improper path resolution during upload_image operations to read arbitrary files outside configured localRoots boundaries.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.2.6, < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
1764394c78b6cfix: enforce localRoots sandbox on Feishu docx upload file reads (#54693)
2 files changed · +91 −35
extensions/feishu/src/docx.test.ts+72 −23 modified@@ -1,10 +1,8 @@ -import { promises as fs } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); const fetchRemoteMediaMock = vi.hoisted(() => vi.fn()); +const loadWebMediaMock = vi.hoisted(() => vi.fn()); const convertMock = vi.hoisted(() => vi.fn()); const documentCreateMock = vi.hoisted(() => vi.fn()); const blockListMock = vi.hoisted(() => vi.fn()); @@ -28,6 +26,9 @@ vi.mock("./runtime.js", () => ({ fetchRemoteMedia: fetchRemoteMediaMock, }, }, + media: { + loadWebMedia: loadWebMediaMock, + }, }), })); @@ -424,22 +425,31 @@ describe("feishu_doc image fetch hardening", () => { }, }); - const localPath = join(tmpdir(), `feishu-docx-upload-${Date.now()}.txt`); - await fs.writeFile(localPath, "hello from local file", "utf8"); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("hello from local file", "utf8"), + fileName: "test-local.txt", + }); const feishuDocTool = resolveFeishuDocTool(); const result = await feishuDocTool.execute("tool-call", { action: "upload_file", doc_token: "doc_1", - file_path: localPath, + file_path: "/tmp/allowed/test-local.txt", filename: "test-local.txt", }); expect(result.details.success).toBe(true); expect(result.details.file_token).toBe("token_1"); expect(result.details.file_name).toBe("test-local.txt"); + // localRoots is not passed — loadWebMedia uses default roots (tmp, media, + // workspace, sandboxes) plus workspace-profile auto-discovery. + expect(loadWebMediaMock).toHaveBeenCalledWith( + expect.stringContaining("test-local.txt"), + expect.objectContaining({ optimizeImages: false }), + ); + expect(driveUploadAllMock).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -449,8 +459,6 @@ describe("feishu_doc image fetch hardening", () => { }), }), ); - - await fs.unlink(localPath); }); it("returns an error when upload_file cannot list placeholder siblings", async () => { @@ -466,23 +474,64 @@ describe("feishu_doc image fetch hardening", () => { data: { items: [] }, }); - const localPath = join(tmpdir(), `feishu-docx-upload-fail-${Date.now()}.txt`); - await fs.writeFile(localPath, "hello from local file", "utf8"); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("hello from local file", "utf8"), + fileName: "test-local.txt", + }); + + const feishuDocTool = resolveFeishuDocTool(); + + const result = await feishuDocTool.execute("tool-call", { + action: "upload_file", + doc_token: "doc_1", + file_path: "/tmp/allowed/test-local.txt", + filename: "test-local.txt", + }); + + expect(result.details.error).toBe("list failed"); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + }); - try { - const feishuDocTool = resolveFeishuDocTool(); + it("rejects traversal paths in upload_file via loadWebMedia sandbox", async () => { + loadWebMediaMock.mockRejectedValueOnce( + new Error("Local media path is not under an allowed directory: /etc/passwd"), + ); - const result = await feishuDocTool.execute("tool-call", { - action: "upload_file", - doc_token: "doc_1", - file_path: localPath, - filename: "test-local.txt", - }); + const feishuDocTool = resolveFeishuDocTool(); - expect(result.details.error).toBe("list failed"); - expect(driveUploadAllMock).not.toHaveBeenCalled(); - } finally { - await fs.unlink(localPath); - } + const result = await feishuDocTool.execute("tool-call", { + action: "upload_file", + doc_token: "doc_1", + file_path: "/etc/passwd", + }); + + expect(result.details.error).toContain("not under an allowed directory"); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + }); + + it("rejects traversal paths in upload_image via loadWebMedia sandbox", async () => { + blockChildrenCreateMock.mockResolvedValueOnce({ + code: 0, + data: { + children: [{ block_type: 27, block_id: "img_block_1" }], + }, + }); + + loadWebMediaMock.mockRejectedValueOnce( + new Error( + "Local media path is not under an allowed directory: /home/admin/.openclaw/openclaw.json", + ), + ); + + const feishuDocTool = resolveFeishuDocTool(); + + const result = await feishuDocTool.execute("tool-call", { + action: "upload_image", + doc_token: "doc_1", + file_path: "/home/admin/.openclaw/openclaw.json", + }); + + expect(result.details.error).toContain("not under an allowed directory"); + expect(driveUploadAllMock).not.toHaveBeenCalled(); }); });
extensions/feishu/src/docx.ts+19 −12 modified@@ -1,6 +1,6 @@ -import { existsSync, promises as fs } from "node:fs"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; -import { isAbsolute } from "node:path"; +import { isAbsolute, resolve } from "node:path"; import { basename } from "node:path"; import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; @@ -536,11 +536,15 @@ async function resolveUploadInput( const absolutePath = isAbsolute(imageInput); if (unambiguousPath || (absolutePath && existsSync(candidate))) { - const buffer = await fs.readFile(candidate); - if (buffer.length > maxBytes) { - throw new Error(`Local file exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`); - } - return { buffer, fileName: explicitFileName ?? basename(candidate) }; + // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu). + // localRoots left undefined so loadWebMedia uses default roots (tmp, media, + // workspace, sandboxes) plus workspace-profile auto-discovery. + const resolvedPath = resolve(candidate); + const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedPath, { + maxBytes, + optimizeImages: false, + }); + return { buffer: loaded.buffer, fileName: explicitFileName ?? basename(candidate) }; } if (absolutePath && !existsSync(candidate)) { @@ -594,12 +598,15 @@ async function resolveUploadInput( }; } - const buffer = await fs.readFile(filePath!); - if (buffer.length > maxBytes) { - throw new Error(`Local file exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`); - } + // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu). + // localRoots left undefined — see comment above. + const resolvedFilePath = resolve(filePath!); + const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedFilePath, { + maxBytes, + optimizeImages: false, + }); return { - buffer, + buffer: loaded.buffer, fileName: explicitFileName || basename(filePath!), }; }
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/advisories/GHSA-qf48-qfv4-jjm9ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-qf48-qfv4-jjm9nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-41363ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-arbitrary-file-read-via-feishu-upload-image-parameternvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/commit/764394c78b6c22c5b53c3cd132d27ff36340bf45ghsaWEB
News mentions
0No linked articles in our index yet.