Root cause
Resource abuse & availability
feat(telegram): add sticker support with vision caching
How AI contributed: New attack surfacePublished Mar 2, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary OpenClaw did not consistently enforce configured inbound media byte limits before buffering remote media in several channel ingestion paths. A remote sender could trigger oversized downloads and memory pressure before rejection. Affected Packages / Versions · Package: openclaw (npm) · Affected versions: <= 2026.2.21-2 (latest published at triage time) · Fixed in: 2026.2.22 (planned next release) Impact An attacker could cause elevated memory usage and potential process instability (denial of service) by sending oversized media payloads. Fix Commit(s) · 73d93dee64127a26f1acd09d0403b794cdeb4f5c Release Process Note patched_versions is pre-set to the planned next release (2026.2.22). After that npm release is published, this advisory can be published without further version-field edits. OpenClaw thanks @tdjackey for reporting.
Claude Code added Telegram sticker support in src/telegram/bot/delivery.ts that called fetchRemoteMedia without maxBytes and passed a limit only to saveMediaBuffer after the payload was already buffered, so a remote sender could force oversized downloads and memory pressure. The fix routes several channel attachment downloads, including the Teams and BlueBubbles paths, through fetchRemoteMedia so the size cap is enforced during the fetch.
Root cause
feat(telegram): add sticker support with vision caching
Fix
fix: enforce inbound media max-bytes during remote fetch
Fix by Peter Steinberger · no AI marker found
Case facts
diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.tsindex c2489300c2c..f950417c79e 100644--- a/src/telegram/bot/delivery.ts+++ b/src/telegram/bot/delivery.ts@@ -21,7 +21,8 @@ import { loadWebMedia } from "../../web/media.js"; import { buildInlineKeyboard } from "../send.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";-import type { TelegramContext } from "./types.js";+import type { StickerMetadata, TelegramContext } from "./types.js";+import { getCachedSticker } from "../sticker-cache.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;@@ -261,8 +262,79 @@ export async function resolveMedia( maxBytes: number, token: string, proxyFetch?: typeof fetch,-): Promise<{ path: string; contentType?: string; placeholder: string } | null> {+): Promise<{+ path: string;+ contentType?: string;+ placeholder: string;+ stickerMetadata?: StickerMetadata;+} | null> { const msg = ctx.message;++ // Handle stickers separately - only static stickers (WEBP) are supported+ if (msg.sticker) {+ const sticker = msg.sticker;+ // Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported+ if (sticker.is_animated || sticker.is_video) {+ logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");+ return null;+ }+ if (!sticker.file_id) return null;++ try {+ const file = await ctx.getFile();+ if (!file.file_path) {+ logVerbose("telegram: getFile returned no file_path for sticker");+ return null;+ }+ const fetchImpl = proxyFetch ?? globalThis.fetch;+ if (!fetchImpl) {+ logVerbose("telegram: fetch not available for sticker download");+ return null;+ }+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;+ const fetched = await fetchRemoteMedia({+ url,+ fetchImpl,+ filePathHint: file.file_path,+ });+ const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);++ // Check sticker cache for existing description+ const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;+ if (cached) {+ logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);+ return {+ path: saved.path,+ contentType: saved.contentType,+ placeholder: "<media:sticker>",+ stickerMetadata: {+ emoji: cached.emoji,+ setName: cached.setName,+ fileId: cached.fileId,+ fileUniqueId: sticker.file_unique_id,+ cachedDescription: cached.description,+ },+ };+ }++ // Cache miss - return metadata for vision processing+ return {+ path: saved.path,+ contentType: saved.contentType,+ placeholder: "<media:sticker>",+ stickerMetadata: {+ emoji: sticker.emoji ?? undefined,+ setName: sticker.set_name ?? undefined,+ fileId: sticker.file_id,+ fileUniqueId: sticker.file_unique_id,+ },+ };+ } catch (err) {+ logVerbose(`telegram: failed to process sticker: ${err}`);+ return null;+ }+ }+ const m = msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice; if (!m?.file_id) return null;diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.tsindex 3a49871d312..dc6496e2aed 100644--- a/extensions/msteams/src/attachments/download.ts+++ b/extensions/msteams/src/attachments/download.ts@@ -88,0 +89 @@ async function fetchWithAuthFallback(params: {+ requestInit?: RequestInit;@@ -93 +94 @@ async function fetchWithAuthFallback(params: {- const firstAttempt = await fetchFn(params.url);+ const firstAttempt = await fetchFn(params.url, params.requestInit);@@ -110,0 +112,2 @@ async function fetchWithAuthFallback(params: {+ const authHeaders = new Headers(params.requestInit?.headers);+ authHeaders.set("Authorization", `Bearer ${token}`);@@ -112 +115,2 @@ async function fetchWithAuthFallback(params: {- headers: { Authorization: `Bearer ${token}` },+ ...params.requestInit,+ headers: authHeaders,@@ -120 +124 @@ async function fetchWithAuthFallback(params: {- const redirectRes = await fetchFn(redirectUrl);+ const redirectRes = await fetchFn(redirectUrl, params.requestInit);@@ -127,0 +132,2 @@ async function fetchWithAuthFallback(params: {+ const redirectAuthHeaders = new Headers(params.requestInit?.headers);+ redirectAuthHeaders.set("Authorization", `Bearer ${token}`);@@ -129 +135,2 @@ async function fetchWithAuthFallback(params: {- headers: { Authorization: `Bearer ${token}` },+ ...params.requestInit,+ headers: redirectAuthHeaders,@@ -144,0 +152,13 @@ async function fetchWithAuthFallback(params: {+function resolveRequestUrl(input: RequestInfo | URL): string {+ if (typeof input === "string") {+ return input;+ }+ if (input instanceof URL) {+ return input.toString();+ }+ if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {+ return input.url;+ }+ return String(input);+}+@@ -241 +261 @@ export async function downloadMSTeamsAttachments(params: {- const res = await fetchWithAuthFallback({+ const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({@@ -243,4 +263,11 @@ export async function downloadMSTeamsAttachments(params: {- tokenProvider: params.tokenProvider,- fetchFn: params.fetchFn,- allowHosts,- authAllowHosts,+ fetchImpl: (input, init) =>+ fetchWithAuthFallback({+ url: resolveRequestUrl(input),+ tokenProvider: params.tokenProvider,+ fetchFn: params.fetchFn,+ requestInit: init,+ allowHosts,+ authAllowHosts,+ }),+ filePathHint: candidate.fileHint ?? candidate.url,+ maxBytes: params.maxBytes,@@ -248,7 +274,0 @@ export async function downloadMSTeamsAttachments(params: {- if (!res.ok) {- continue;- }- const buffer = Buffer.from(await res.arrayBuffer());- if (buffer.byteLength > params.maxBytes) {- continue;- }@@ -256,2 +276,2 @@ export async function downloadMSTeamsAttachments(params: {- buffer,- headerMime: res.headers.get("content-type"),+ buffer: fetched.buffer,+ headerMime: fetched.contentType,@@ -262 +282 @@ export async function downloadMSTeamsAttachments(params: {- buffer,+ fetched.buffer,diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.tsindex 72133f8145f..fd73909fefe 100644--- a/extensions/msteams/src/attachments/graph.ts+++ b/extensions/msteams/src/attachments/graph.ts@@ -16,0 +17,13 @@ import type {+function resolveRequestUrl(input: RequestInfo | URL): string {+ if (typeof input === "string") {+ return input;+ }+ if (input instanceof URL) {+ return input.toString();+ }+ if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {+ return input.url;+ }+ return String(input);+}+@@ -268,25 +281,11 @@ export async function downloadMSTeamsGraphMedia(params: {- const spRes = await fetchFn(sharesUrl, {- headers: { Authorization: `Bearer ${accessToken}` },- redirect: "follow",- });-- if (spRes.ok) {- const buffer = Buffer.from(await spRes.arrayBuffer());- if (buffer.byteLength <= params.maxBytes) {- const mime = await getMSTeamsRuntime().media.detectMime({- buffer,- headerMime: spRes.headers.get("content-type") ?? undefined,- filePath: name,- });- const originalFilename = params.preserveFilenames ? name : undefined;- const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(- buffer,- mime ?? "application/octet-stream",- "inbound",- params.maxBytes,- originalFilename,- );- sharePointMedia.push({- path: saved.path,- contentType: saved.contentType,- placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),+ const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({+ url: sharesUrl,+ filePathHint: name,+ maxBytes: params.maxBytes,+ fetchImpl: async (input, init) => {+ const headers = new Headers(init?.headers);+ headers.set("Authorization", `Bearer ${accessToken}`);+ return await fetchFn(resolveRequestUrl(input), {+ ...init,+ headers,+ redirect: "follow",@@ -294,3 +293,21 @@ export async function downloadMSTeamsGraphMedia(params: {- downloadedReferenceUrls.add(shareUrl);- }- }+ },+ });+ const mime = await getMSTeamsRuntime().media.detectMime({+ buffer: fetched.buffer,+ headerMime: fetched.contentType,+ filePath: name,+ });+ const originalFilename = params.preserveFilenames ? name : undefined;+ const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(+ fetched.buffer,+ mime ?? "application/octet-stream",+ "inbound",+ params.maxBytes,+ originalFilename,+ );+ sharePointMedia.push({+ path: saved.path,+ contentType: saved.contentType,+ placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),+ });+ downloadedReferenceUrls.add(shareUrl);diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.tsindex e60022fca24..9fcb747c892 100644--- a/extensions/bluebubbles/src/attachments.ts+++ b/extensions/bluebubbles/src/attachments.ts@@ -6,0 +7 @@ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";+import { getBlueBubblesRuntime } from "./runtime.js";@@ -59,0 +61,13 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {+function resolveRequestUrl(input: RequestInfo | URL): string {+ if (typeof input === "string") {+ return input;+ }+ if (input instanceof URL) {+ return input.toString();+ }+ if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {+ return input.url;+ }+ return String(input);+}+@@ -74,9 +87,0 @@ export async function downloadBlueBubblesAttachment(- const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);- if (!res.ok) {- const errorText = await res.text().catch(() => "");- throw new Error(- `BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,- );- }- const contentType = res.headers.get("content-type") ?? undefined;- const buf = new Uint8Array(await res.arrayBuffer());@@ -84,2 +89,22 @@ export async function downloadBlueBubblesAttachment(- if (buf.byteLength > maxBytes) {- throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);+ try {+ const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({+ url,+ filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",+ maxBytes,+ fetchImpl: async (input, init) =>+ await blueBubblesFetchWithTimeout(+ resolveRequestUrl(input),+ { ...init, method: init?.method ?? "GET" },+ opts.timeoutMs,+ ),+ });+ return {+ buffer: new Uint8Array(fetched.buffer),+ contentType: fetched.contentType ?? attachment.mimeType ?? undefined,+ };+ } catch (error) {+ const text = error instanceof Error ? error.message : String(error);+ if (/(?:maxBytes|content length|payload exceeds)/i.test(text)) {+ throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);+ }+ throw new Error(`BlueBubbles attachment download failed: ${text}`);@@ -87 +111,0 @@ export async function downloadBlueBubblesAttachment(- return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };AI-assisted change d3d3310ba1c3ee033c350dc0a53d62ac7988e2440fd98c462d719a99887b0cee · Fix 0627b5c05d0a587c71926e3bd0407f0e916a5f74b86e53bb36826e63911f4cf4