Medium severity4.3NVD Advisory· Published Apr 28, 2026· Updated Apr 30, 2026
CVE-2026-41408
CVE-2026-41408
Description
OpenClaw before 2026.3.31 contains a resource exhaustion vulnerability in media downloads that bypasses core safety limits for file size, count, and cleanup operations. Attackers can exhaust disk space by downloading media files without triggering intended safety restrictions, causing availability impact.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.31 | 2026.3.31 |
Affected products
1Patches
12194587d70d2fix(tlon): cap inbound image downloads (#58223)
3 files changed · +156 −48
CHANGELOG.md+1 −0 modified@@ -232,6 +232,7 @@ Docs: https://docs.openclaw.ai - Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026. - Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava. - Subagents/announcements: preserve the requester agent id for inline deterministic tool spawns so named agents without channel bindings can still announce completions through the correct owner session. (#55437) Thanks @kAIborg24. +- Tlon/media: route inbound image downloads through the shared media store, cap each download at 6 MB, and stop after 8 images per message so large Tlon posts no longer balloon local media storage. Thanks @AntAISecurityLab and @vincentkoc. - Telegram/Anthropic streaming: replace raw invalid stream-order provider errors with a safe retry message so internal `message_start/message_stop` failures do not leak into chats. (#55408) Thanks @imydal. - Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan. - LINE/webhooks: cap shared concurrent pre-verify webhook body reads so excess requests are rejected before entering the LINE body handler. Thanks @nexrin and @vincentkoc.
extensions/tlon/src/monitor/media.test.ts+101 −0 added@@ -0,0 +1,101 @@ +import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { + const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>(); + return { + ...actual, + fetchRemoteMedia: vi.fn(), + saveMediaBuffer: vi.fn(), + }; +}); + +describe("tlon monitor media", () => { + async function loadMediaModule() { + const mediaRuntime = await import("openclaw/plugin-sdk/media-runtime"); + const mediaModule = await import("./media.js"); + return { + fetchRemoteMedia: vi.mocked(mediaRuntime.fetchRemoteMedia), + saveMediaBuffer: vi.mocked(mediaRuntime.saveMediaBuffer), + ...mediaModule, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("caps extracted images at eight per message", async () => { + const { extractImageBlocks } = await loadMediaModule(); + const content = Array.from({ length: 10 }, (_, index) => ({ + block: { image: { src: `https://example.com/${index}.png`, alt: `image-${index}` } }, + })); + + const images = extractImageBlocks(content); + + expect(images).toHaveLength(8); + expect(images.map((image) => image.url)).toEqual( + Array.from({ length: 8 }, (_, index) => `https://example.com/${index}.png`), + ); + }); + + it("stores fetched media through the shared inbound media store with the image cap", async () => { + const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule(); + + fetchRemoteMedia.mockResolvedValue({ + buffer: Buffer.from("image-data"), + contentType: "image/png", + fileName: "photo.png", + }); + saveMediaBuffer.mockResolvedValue({ + id: "photo---uuid.png", + path: "/tmp/openclaw/media/inbound/photo---uuid.png", + size: "image-data".length, + contentType: "image/png", + }); + + const result = await downloadMedia("https://example.com/photo.png"); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://example.com/photo.png", + maxBytes: MAX_IMAGE_BYTES, + readIdleTimeoutMs: 30_000, + requestInit: { method: "GET" }, + }), + ); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("image-data"), + "image/png", + "inbound", + MAX_IMAGE_BYTES, + "photo.png", + ); + expect(result).toEqual({ + localPath: "/tmp/openclaw/media/inbound/photo---uuid.png", + contentType: "image/png", + originalUrl: "https://example.com/photo.png", + }); + }); + + it("returns null when the fetch exceeds the image cap", async () => { + const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule(); + + fetchRemoteMedia.mockRejectedValue( + new Error( + `Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`, + ), + ); + + const result = await downloadMedia("https://example.com/photo.png"); + + expect(result).toBeNull(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + }); +});
extensions/tlon/src/monitor/media.ts+54 −48 modified@@ -1,15 +1,15 @@ import { randomUUID } from "node:crypto"; -import { createWriteStream } from "node:fs"; -import { mkdir } from "node:fs/promises"; -import { homedir } from "node:os"; +import { mkdir, writeFile } from "node:fs/promises"; import * as path from "node:path"; -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; -import { fetchWithSsrFGuard } from "../../runtime-api.js"; +import { + fetchRemoteMedia, + MAX_IMAGE_BYTES, + saveMediaBuffer, +} from "openclaw/plugin-sdk/media-runtime"; import { getDefaultSsrFPolicy } from "../urbit/context.js"; -// Default to OpenClaw workspace media directory -const DEFAULT_MEDIA_DIR = path.join(homedir(), ".openclaw", "workspace", "media", "inbound"); +const MAX_IMAGES_PER_MESSAGE = 8; +const TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; export interface ExtractedImage { url: string; @@ -39,6 +39,9 @@ export function extractImageBlocks(content: unknown): ExtractedImage[] { url: verse.block.image.src, alt: verse.block.image.alt, }); + if (images.length >= MAX_IMAGES_PER_MESSAGE) { + break; + } } } @@ -51,7 +54,7 @@ export function extractImageBlocks(content: unknown): ExtractedImage[] { */ export async function downloadMedia( url: string, - mediaDir: string = DEFAULT_MEDIA_DIR, + mediaDir?: string, ): Promise<DownloadedMedia | null> { try { // Validate URL is http/https before fetching @@ -61,54 +64,57 @@ export async function downloadMedia( return null; } - // Ensure media directory exists - await mkdir(mediaDir, { recursive: true }); - - // Fetch with SSRF protection - // Use fetchWithSsrFGuard directly (not urbitFetch) to preserve the full URL path - const { response, release } = await fetchWithSsrFGuard({ + const fetched = await fetchRemoteMedia({ url, - init: { method: "GET" }, - policy: getDefaultSsrFPolicy(), - auditContext: "tlon-media-download", + maxBytes: MAX_IMAGE_BYTES, + readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS, + ssrfPolicy: getDefaultSsrFPolicy(), + requestInit: { method: "GET" }, }); - try { - if (!response.ok) { - console.error(`[tlon-media] Failed to fetch ${url}: ${response.status}`); - return null; - } - - // Determine content type and extension - const contentType = response.headers.get("content-type") || "application/octet-stream"; - const ext = getExtensionFromContentType(contentType) || getExtensionFromUrl(url) || "bin"; - - // Generate unique filename - const filename = `${randomUUID()}.${ext}`; - const localPath = path.join(mediaDir, filename); - - // Stream to file - const body = response.body; - if (!body) { - console.error(`[tlon-media] No response body for ${url}`); - return null; - } - - const writeStream = createWriteStream(localPath); - await pipeline(Readable.fromWeb(body as any), writeStream); - + if (!mediaDir) { + const saved = await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + MAX_IMAGE_BYTES, + fetched.fileName, + ); return { - localPath, - contentType, + localPath: saved.path, + contentType: saved.contentType ?? fetched.contentType ?? "application/octet-stream", originalUrl: url, }; - } finally { - await release(); } - } catch (error: any) { - console.error(`[tlon-media] Error downloading ${url}: ${error?.message ?? String(error)}`); + + await mkdir(mediaDir, { recursive: true }); + const ext = + getExtensionFromFileName(fetched.fileName) || + getExtensionFromContentType(fetched.contentType ?? "") || + getExtensionFromUrl(url) || + "bin"; + const localPath = path.join(mediaDir, `${randomUUID()}.${ext}`); + await writeFile(localPath, fetched.buffer); + + return { + localPath, + contentType: fetched.contentType ?? "application/octet-stream", + originalUrl: url, + }; + } catch (error: unknown) { + console.error( + `[tlon-media] Error downloading ${url}: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +function getExtensionFromFileName(fileName?: string): string | null { + if (!fileName) { return null; } + const ext = path.extname(fileName).replace(/^\./, ""); + return ext || null; } function getExtensionFromContentType(contentType: string): string | null {
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
5- github.com/openclaw/openclaw/commit/2194587d70d2aef863508b945319c5a7c88b12cenvdPatchWEB
- github.com/advisories/GHSA-4g5x-2jfc-xm98ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-4g5x-2jfc-xm98nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-41408ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-disk-exhaustion-via-media-download-bypassnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.