diff --git a/extensions/telegram/src/audit-membership-runtime.ts b/extensions/telegram/src/audit-membership-runtime.ts index 3440ff43f68..d8ae0788d6b 100644 --- a/extensions/telegram/src/audit-membership-runtime.ts +++ b/extensions/telegram/src/audit-membership-runtime.ts @@ -1,5 +1,6 @@ // Telegram plugin module implements audit membership runtime behavior. import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-utility-runtime"; import type { @@ -13,6 +14,8 @@ import { makeProxyFetch } from "./proxy.js"; type TelegramApiOk = { ok: true; result: T }; type TelegramApiErr = { ok: false; description?: string }; type TelegramGroupMembershipAuditData = Omit; +// Telegram getChatMember responses are tiny (< 1 KiB). 4 MiB guards against hostile endpoints. +const TELEGRAM_BOT_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; type TelegramChatMemberResult = { status?: string }; export async function auditTelegramGroupMembershipImpl( @@ -30,7 +33,9 @@ export async function auditTelegramGroupMembershipImpl( try { const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`; const res = await fetchWithTimeout(url, {}, params.timeoutMs, fetcher); - const json = (await res.json()) as TelegramApiOk | TelegramApiErr; + const json = JSON.parse( + (await readResponseWithLimit(res, TELEGRAM_BOT_API_MAX_RESPONSE_BYTES)).toString("utf8"), + ) as TelegramApiOk | TelegramApiErr; if (!res.ok || !isRecord(json) || !json.ok) { const desc = isRecord(json) && !json.ok && typeof json.description === "string" diff --git a/extensions/telegram/src/probe.test.ts b/extensions/telegram/src/probe.test.ts index b719e78aed1..c403f2f5da4 100644 --- a/extensions/telegram/src/probe.test.ts +++ b/extensions/telegram/src/probe.test.ts @@ -16,6 +16,20 @@ vi.mock("./proxy.js", () => ({ makeProxyFetch, })); +// readResponseWithLimit requires a real Response body; pass-through so existing plain-object +// fetch mocks continue to work. The size-cap behavior is verified by the proof script. +vi.mock("openclaw/plugin-sdk/response-limit-runtime", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readResponseWithLimit: async (response: Response) => { + const data = await response.json(); + return Buffer.from(JSON.stringify(data)); + }, + }; +}); + describe("probeTelegram retry logic", () => { const token = "test-token"; const timeoutMs = 5000; diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index c302b6eee14..5841ee71b60 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -2,6 +2,7 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-utility-runtime"; import { normalizeTelegramBotInfo, type TelegramBotInfo } from "./bot-info.js"; import { @@ -42,6 +43,9 @@ export type TelegramProbeOptions = { const probeTransportCache = new Map(); const MAX_PROBE_TRANSPORT_CACHE_SIZE = 64; +// Generous cap: Telegram Bot API responses for getMe/getWebhookInfo are always < 1 KiB. +// 4 MiB guards against a misbehaving or hostile API endpoint streaming an oversized payload. +const TELEGRAM_BOT_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; export function resetTelegramProbeFetcherCacheForTests(): void { probeTransportCache.clear(); @@ -186,7 +190,9 @@ export async function probeTelegram( ); } - const meJson = (await meRes.json()) as { + const meJson = JSON.parse( + (await readResponseWithLimit(meRes, TELEGRAM_BOT_API_MAX_RESPONSE_BYTES)).toString("utf8"), + ) as { ok?: boolean; description?: string; result?: unknown; @@ -229,7 +235,11 @@ export async function probeTelegram( Math.max(1, Math.min(timeoutBudgetMs, webhookRemainingBudgetMs)), fetcher, ); - const webhookJson = (await webhookRes.json()) as { + const webhookJson = JSON.parse( + (await readResponseWithLimit(webhookRes, TELEGRAM_BOT_API_MAX_RESPONSE_BYTES)).toString( + "utf8", + ), + ) as { ok?: boolean; result?: { url?: string; has_custom_certificate?: boolean }; }; diff --git a/extensions/telegram/src/telegram-ingress-worker.runtime.ts b/extensions/telegram/src/telegram-ingress-worker.runtime.ts index 1f7ab30143a..ade9f855185 100644 --- a/extensions/telegram/src/telegram-ingress-worker.runtime.ts +++ b/extensions/telegram/src/telegram-ingress-worker.runtime.ts @@ -1,5 +1,6 @@ // Telegram plugin module implements telegram ingress worker behavior. import { parentPort, workerData } from "node:worker_threads"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { normalizeTelegramApiRoot } from "./api-root.js"; import { resolveTelegramTransport } from "./fetch.js"; @@ -17,6 +18,9 @@ import type { const options = workerData as TelegramIngressWorkerOptions; const pollLimit = 100; +// getUpdates can return up to 100 updates; 4 MiB is a generous bound that no legitimate +// Telegram Bot API response will reach, guarding against misbehaving/hostile endpoints. +const TELEGRAM_GET_UPDATES_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const retryInitialMs = 1000; const retryMaxMs = 30_000; let stopped = false; @@ -140,7 +144,11 @@ async function fetchJson(params: { body: JSON.stringify(params.body), signal: controller.signal, }); - const json = (await response.json()) as { + const json = JSON.parse( + (await readResponseWithLimit(response, TELEGRAM_GET_UPDATES_MAX_RESPONSE_BYTES)).toString( + "utf8", + ), + ) as { ok?: unknown; error_code?: unknown; result?: unknown; diff --git a/scripts/proof-telegram-bound.mjs b/scripts/proof-telegram-bound.mjs new file mode 100644 index 00000000000..51126fa4043 --- /dev/null +++ b/scripts/proof-telegram-bound.mjs @@ -0,0 +1,142 @@ +import { once } from "node:events"; +// Proof script: verifies readResponseWithLimit stops Telegram Bot API response reads at the cap. +import { createServer } from "node:http"; +import { resolve } from "node:path"; + +const pkgRoot = resolve(import.meta.dirname, ".."); + +const { readResponseWithLimit } = await import( + `${pkgRoot}/packages/media-core/src/read-response-with-limit.ts` +).catch(() => import("@openclaw/media-core/read-response-with-limit")); + +const CAP = 1 * 1024 * 1024; // 1 MiB proof cap +const STREAM_SIZE = 24 * 1024 * 1024; // 24 MiB – simulates hostile oversized Bot API response + +let allPassed = true; +function check(label, val) { + console.log(` ${val ? "ok" : "FAIL"}: ${label}`); + if (!val) { + allPassed = false; + } +} + +// Server-side byte counter: track how many response bytes were actually written to the socket. +let serverBytesWritten = 0; + +async function withServer(fn) { + serverBytesWritten = 0; + const server = createServer((req, res) => { + if (req.url === "/huge") { + res.writeHead(200, { "Content-Type": "application/json" }); + const chunk = Buffer.alloc(65536, 120); // 64 KiB of 'x' + const header = Buffer.from('{"ok":true,"result":['); + res.write(header); + serverBytesWritten += header.length; + + let sent = header.length; + const writeNext = () => { + if (sent >= STREAM_SIZE) { + const tail = Buffer.from("]}"); + res.write(tail); + serverBytesWritten += tail.length; + res.end(); + return; + } + const ok = res.write(chunk); + serverBytesWritten += chunk.length; + sent += chunk.length; + if (ok) { + setImmediate(writeNext); + } else { + res.once("drain", writeNext); + } + }; + writeNext(); + } else { + const body = JSON.stringify({ + ok: true, + result: { + id: 123456789, + is_bot: true, + username: "my_test_bot", + first_name: "TestBot", + }, + }); + res.writeHead(200, { + "Content-Type": "application/json", + "Content-Length": String(Buffer.byteLength(body)), + }); + res.end(body); + serverBytesWritten += Buffer.byteLength(body); + } + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address(); + try { + await fn(port, server); + } finally { + await new Promise((resolveDone) => { + server.close(resolveDone); + }); + } +} + +console.log(`\n[proof] Telegram Bot API response-limit`); +console.log(` cap=${CAP} bytes (1 MiB), would-stream≈${STREAM_SIZE} bytes (24 MiB)\n`); + +// ── Case 1: readResponseWithLimit rejects oversized body ───────────────────── +await withServer(async (port) => { + serverBytesWritten = 0; + const res = await fetch(`http://127.0.0.1:${port}/huge`); + let err; + try { + await readResponseWithLimit(res, CAP); + } catch (e) { + err = e; + } + // Give server a tick to flush its internal counter before checking + await new Promise((done) => { + setTimeout(done, 50); + }); + const sent = serverBytesWritten; + check(`oversized body rejected (threw=${err != null})`, err != null); + check( + `error message contains limit info: "${err?.message?.slice(0, 80)}"`, + err?.message?.includes("limit") === true, + ); + check( + `server wrote ≈${sent} bytes, well below 24 MiB (stream was cancelled early)`, + sent < STREAM_SIZE * 0.1, + ); +}); + +// ── Negative control: unbounded .json() reads the FULL 24 MiB ──────────────── +await withServer(async (port) => { + serverBytesWritten = 0; + const res2 = await fetch(`http://127.0.0.1:${port}/huge`); + await res2.json().catch(() => undefined); + await new Promise((done) => { + setTimeout(done, 50); + }); + const sent2 = serverBytesWritten; + check( + `negative control: unbounded .json() caused server to write ≈${sent2} bytes (>> ${CAP})`, + sent2 > CAP, + ); +}); + +// ── Case 3: small happy-path response (like real getMe / getUpdates OK) ─────── +await withServer(async (port) => { + const res3 = await fetch(`http://127.0.0.1:${port}/small`); + const buf = await readResponseWithLimit(res3, CAP); + const parsed = JSON.parse(buf.toString("utf8")); + check( + `small response parsed correctly (ok=${parsed?.ok} id=${parsed?.result?.id})`, + parsed?.ok === true && parsed?.result?.id === 123456789, + ); + check(`result bytes within cap (${buf.length} < ${CAP})`, buf.length > 0 && buf.length < CAP); +}); + +console.log(allPassed ? "\nALL PROOF ASSERTIONS PASSED" : "\nSOME ASSERTIONS FAILED"); +process.exit(allPassed ? 0 : 1);