High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026
OpenClaw < 2026.2.14 - Denial of Service via Unbounded URL-backed Media Fetch
CVE-2026-29609
Description
OpenClaw versions prior to 2026.2.14 contain a denial of service vulnerability in the fetchWithGuard function that allocates entire response payloads in memory before enforcing maxBytes limits. Remote attackers can trigger memory exhaustion by serving oversized responses without content-length headers to cause availability loss.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.14 | 2026.2.14 |
Affected products
1Patches
100a08908892dfix(media): bound input media payload sizes
3 files changed · +164 −8
CHANGELOG.md+1 −0 modified@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson. +- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc. - Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root. - Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra. - Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
src/media/input-files.fetch-guard.test.ts+93 −0 added@@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +const fetchWithSsrFGuardMock = vi.fn(); + +vi.mock("../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), +})); + +describe("fetchWithGuard", () => { + it("rejects oversized streamed payloads and cancels the stream", async () => { + let canceled = false; + let pulls = 0; + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])); + }, + pull(controller) { + pulls += 1; + if (pulls === 1) { + controller.enqueue(new Uint8Array([5, 6, 7, 8])); + } + // keep stream open; cancel() should stop it once maxBytes exceeded + }, + cancel() { + canceled = true; + }, + }); + + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(stream, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }), + release, + finalUrl: "https://example.com/file.bin", + }); + + const { fetchWithGuard } = await import("./input-files.js"); + await expect( + fetchWithGuard({ + url: "https://example.com/file.bin", + maxBytes: 6, + timeoutMs: 1000, + maxRedirects: 0, + }), + ).rejects.toThrow("Content too large"); + + // Allow cancel() microtask to run. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(canceled).toBe(true); + expect(release).toHaveBeenCalledTimes(1); + }); +}); + +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"); + await expect( + extractImageContentFromSource( + { type: "base64", data, mediaType: "image/png" }, + { + allowUrl: false, + allowedMimes: new Set(["image/png"]), + maxBytes: 6, + maxRedirects: 0, + timeoutMs: 1, + }, + ), + ).rejects.toThrow("Image too large"); + }); + + it("rejects oversized base64 files before decoding", async () => { + const data = Buffer.alloc(7).toString("base64"); + const { extractFileContentFromSource } = await import("./input-files.js"); + await expect( + extractFileContentFromSource({ + source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" }, + limits: { + allowUrl: false, + allowedMimes: new Set(["text/plain"]), + maxBytes: 6, + maxChars: 100, + maxRedirects: 0, + timeoutMs: 1, + pdf: { maxPages: 1, maxPixels: 1, minTextChars: 1 }, + }, + }), + ).rejects.toThrow("File too large"); + }); +});
src/media/input-files.ts+70 −8 modified@@ -110,6 +110,29 @@ 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; + label: "Image" | "File"; +}): void { + const estimated = estimateBase64DecodedBytes(params.data); + if (estimated > params.maxBytes) { + throw new Error( + `${params.label} too large: ${estimated} bytes (limit: ${params.maxBytes} bytes)`, + ); + } +} + export function normalizeMimeType(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -163,18 +186,13 @@ export async function fetchWithGuard(params: { const contentLength = response.headers.get("content-length"); if (contentLength) { - const size = parseInt(contentLength, 10); - if (size > params.maxBytes) { + const size = Number(contentLength); + if (Number.isFinite(size) && size > params.maxBytes) { throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`); } } - const buffer = Buffer.from(await response.arrayBuffer()); - if (buffer.byteLength > params.maxBytes) { - throw new Error( - `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`, - ); - } + const buffer = await readResponseWithLimit(response, params.maxBytes); const contentType = response.headers.get("content-type") || undefined; const parsed = parseContentType(contentType); @@ -185,6 +203,48 @@ export async function fetchWithGuard(params: { } } +async function readResponseWithLimit(res: Response, maxBytes: number): Promise<Buffer> { + const body = res.body; + if (!body || typeof body.getReader !== "function") { + const fallback = Buffer.from(await res.arrayBuffer()); + if (fallback.byteLength > maxBytes) { + throw new Error(`Content too large: ${fallback.byteLength} bytes (limit: ${maxBytes} bytes)`); + } + return fallback; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value?.length) { + total += value.length; + if (total > maxBytes) { + try { + await reader.cancel(); + } catch {} + throw new Error(`Content too large: ${total} bytes (limit: ${maxBytes} bytes)`); + } + chunks.push(value); + } + } + } finally { + try { + reader.releaseLock(); + } catch {} + } + + return Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + total, + ); +} + function decodeTextContent(buffer: Buffer, charset: string | undefined): string { const encoding = charset?.trim().toLowerCase() || "utf-8"; try { @@ -268,6 +328,7 @@ export async function extractImageContentFromSource( if (!source.data) { throw new Error("input_image base64 source missing 'data' field"); } + rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "Image" }); const mimeType = normalizeMimeType(source.mediaType) ?? "image/png"; if (!limits.allowedMimes.has(mimeType)) { throw new Error(`Unsupported image MIME type: ${mimeType}`); @@ -320,6 +381,7 @@ export async function extractFileContentFromSource(params: { if (!source.data) { throw new Error("input_file base64 source missing 'data' field"); } + rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "File" }); const parsed = parseContentType(source.mediaType); mimeType = parsed.mimeType; charset = parsed.charset;
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
4- github.com/openclaw/openclaw/commit/00a08908892d1743d1fc52e5cbd9499dd5da2fe0ghsapatchWEB
- github.com/advisories/GHSA-j27p-hq53-9wgcghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-j27p-hq53-9wgcghsavendor-advisoryWEB
- www.vulncheck.com/advisories/openclaw-denial-of-service-via-unbounded-url-backed-media-fetchmitrethird-party-advisory
News mentions
0No linked articles in our index yet.