Moderate severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026
OpenClaw < 2026.2.14 - Denial of Service via Large Base64 Media File Decoding
CVE-2026-29612
Description
OpenClaw versions prior to 2026.2.14 decode base64-backed media inputs into buffers before enforcing decoded-size budget limits, allowing attackers to trigger large memory allocations. Remote attackers can supply oversized base64 payloads to cause memory pressure and denial of service.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.14 | 2026.2.14 |
clawdbotnpm | <= 2026.1.24-3 | — |
Affected products
1Patches
131791233d604fix(security): reject oversized base64 before decode
6 files changed · +74 −29
CHANGELOG.md+2 −0 modified@@ -15,6 +15,8 @@ Docs: https://docs.openclaw.ai - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. - Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. - Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc. +- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc. +- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc. ## 2026.2.14
src/gateway/chat-attachments.test.ts+13 −5 modified@@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildMessageWithAttachments, type ChatAttachment, @@ -44,16 +44,20 @@ describe("buildMessageWithAttachments", () => { }); it("rejects images over limit", () => { - const big = Buffer.alloc(6_000_000, 0).toString("base64"); + const big = "A".repeat(10_000); const att: ChatAttachment = { type: "image", mimeType: "image/png", fileName: "big.png", content: big, }; - expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 5_000_000 })).toThrow( + const fromSpy = vi.spyOn(Buffer, "from"); + expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 16 })).toThrow( /exceeds size limit/i, ); + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); }); @@ -94,7 +98,8 @@ describe("parseMessageWithAttachments", () => { }); it("rejects images over limit", async () => { - const big = Buffer.alloc(6_000_000, 0).toString("base64"); + const big = "A".repeat(10_000); + const fromSpy = vi.spyOn(Buffer, "from"); await expect( parseMessageWithAttachments( "x", @@ -106,9 +111,12 @@ describe("parseMessageWithAttachments", () => { content: big, }, ], - { maxBytes: 5_000_000, log: { warn: () => {} } }, + { maxBytes: 16, log: { warn: () => {} } }, ), ).rejects.toThrow(/exceeds size limit/i); + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); it("sniffs mime when missing", async () => {
src/gateway/chat-attachments.ts+10 −14 modified@@ -1,3 +1,4 @@ +import { estimateBase64DecodedBytes } from "../media/base64.js"; import { detectMime } from "../media/mime.js"; export type ChatAttachment = { @@ -54,6 +55,11 @@ function isImageMime(mime?: string): boolean { return typeof mime === "string" && mime.startsWith("image/"); } +function isValidBase64(value: string): boolean { + // Minimal validation; avoid full decode allocations for large payloads. + return value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value); +} + /** * Parse attachments and extract images as structured content blocks. * Returns the message text and an array of image content blocks @@ -91,15 +97,10 @@ export async function parseMessageWithAttachments( if (dataUrlMatch) { b64 = dataUrlMatch[1]; } - // Basic base64 sanity: length multiple of 4 and charset check. - if (b64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(b64)) { - throw new Error(`attachment ${label}: invalid base64 content`); - } - try { - sizeBytes = Buffer.from(b64, "base64").byteLength; - } catch { + if (!isValidBase64(b64)) { throw new Error(`attachment ${label}: invalid base64 content`); } + sizeBytes = estimateBase64DecodedBytes(b64); if (sizeBytes <= 0 || sizeBytes > maxBytes) { throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`); } @@ -163,15 +164,10 @@ export function buildMessageWithAttachments( let sizeBytes = 0; const b64 = content.trim(); - // Basic base64 sanity: length multiple of 4 and charset check. - if (b64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(b64)) { - throw new Error(`attachment ${label}: invalid base64 content`); - } - try { - sizeBytes = Buffer.from(b64, "base64").byteLength; - } catch { + if (!isValidBase64(b64)) { throw new Error(`attachment ${label}: invalid base64 content`); } + sizeBytes = estimateBase64DecodedBytes(b64); if (sizeBytes <= 0 || sizeBytes > maxBytes) { throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`); }
src/media/base64.ts+37 −0 added@@ -0,0 +1,37 @@ +export function estimateBase64DecodedBytes(base64: string): number { + // Avoid `trim()`/`replace()` here: they allocate a second (potentially huge) string. + // We only need a conservative decoded-size estimate to enforce budgets before Buffer.from(..., "base64"). + let effectiveLen = 0; + for (let i = 0; i < base64.length; i += 1) { + const code = base64.charCodeAt(i); + // Treat ASCII control + space as whitespace; base64 decoders commonly ignore these. + if (code <= 0x20) { + continue; + } + effectiveLen += 1; + } + + if (effectiveLen === 0) { + return 0; + } + + let padding = 0; + // Find last non-whitespace char(s) to detect '=' padding without allocating/copying. + let end = base64.length - 1; + while (end >= 0 && base64.charCodeAt(end) <= 0x20) { + end -= 1; + } + if (end >= 0 && base64[end] === "=") { + padding = 1; + end -= 1; + while (end >= 0 && base64.charCodeAt(end) <= 0x20) { + end -= 1; + } + if (end >= 0 && base64[end] === "=") { + padding = 2; + } + } + + const estimated = Math.floor((effectiveLen * 3) / 4) - padding; + return Math.max(0, estimated); +}
src/media/input-files.fetch-guard.test.ts+11 −0 modified@@ -58,6 +58,7 @@ describe("base64 size guards", () => { it("rejects oversized base64 images before decoding", async () => { const data = Buffer.alloc(7).toString("base64"); const { extractImageContentFromSource } = await import("./input-files.js"); + const fromSpy = vi.spyOn(Buffer, "from"); await expect( extractImageContentFromSource( { type: "base64", data, mediaType: "image/png" }, @@ -70,11 +71,17 @@ describe("base64 size guards", () => { }, ), ).rejects.toThrow("Image too large"); + + // Regression check: the oversize reject must happen before Buffer.from(..., "base64") allocates. + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); it("rejects oversized base64 files before decoding", async () => { const data = Buffer.alloc(7).toString("base64"); const { extractFileContentFromSource } = await import("./input-files.js"); + const fromSpy = vi.spyOn(Buffer, "from"); await expect( extractFileContentFromSource({ source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" }, @@ -89,5 +96,9 @@ describe("base64 size guards", () => { }, }), ).rejects.toThrow("File too large"); + + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); });
src/media/input-files.ts+1 −10 modified@@ -1,6 +1,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { logWarn } from "../logger.js"; +import { estimateBase64DecodedBytes } from "./base64.js"; type CanvasModule = typeof import("@napi-rs/canvas"); type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); @@ -110,16 +111,6 @@ export const DEFAULT_INPUT_PDF_MAX_PAGES = 4; export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000; export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200; -function estimateBase64DecodedBytes(base64: string): number { - const cleaned = base64.trim().replace(/\s+/g, ""); - if (!cleaned) { - return 0; - } - const padding = cleaned.endsWith("==") ? 2 : cleaned.endsWith("=") ? 1 : 0; - const estimated = Math.floor((cleaned.length * 3) / 4) - padding; - return Math.max(0, estimated); -} - function rejectOversizedBase64Payload(params: { data: string; maxBytes: number;
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
6- github.com/openclaw/openclaw/commit/31791233d60495725fa012745dde8d6ee69e9595ghsapatchWEB
- github.com/advisories/GHSA-w2cg-vxx6-5xjgghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-w2cg-vxx6-5xjgghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-29612ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-denial-of-service-via-large-base-media-file-decodingghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.14ghsaWEB
News mentions
0No linked articles in our index yet.