diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf8d4862d8..48290d3389f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08. - Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. - Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata. - Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang. diff --git a/src/telegram/webhook.test.ts b/src/telegram/webhook.test.ts index 1b630b034df..0f2736a30b9 100644 --- a/src/telegram/webhook.test.ts +++ b/src/telegram/webhook.test.ts @@ -88,6 +88,70 @@ async function postWebhookJson(params: { ); } +async function postWebhookHeadersOnly(params: { + port: number; + path: string; + declaredLength: number; + secret?: string; + timeoutMs?: number; +}): Promise<{ statusCode: number; body: string }> { + return await new Promise((resolve, reject) => { + let settled = false; + const finishResolve = (value: { statusCode: number; body: string }) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve(value); + }; + const finishReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }; + + const req = request( + { + hostname: "127.0.0.1", + port: params.port, + path: params.path, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(params.declaredLength), + ...(params.secret ? { "x-telegram-bot-api-secret-token": params.secret } : {}), + }, + }, + (res) => { + collectResponseBody(res, (payload) => { + finishResolve(payload); + req.destroy(); + }); + }, + ); + + const timeout = setTimeout(() => { + req.destroy( + new Error(`webhook header-only post timed out after ${params.timeoutMs ?? 5_000}ms`), + ); + finishReject(new Error("timed out waiting for webhook response")); + }, params.timeoutMs ?? 5_000); + + req.on("error", (error) => { + if (settled && (error as NodeJS.ErrnoException).code === "ECONNRESET") { + return; + } + finishReject(error); + }); + + req.flushHeaders(); + }); +} + function createDeterministicRng(seed: number): () => number { let state = seed >>> 0; return () => { @@ -399,7 +463,34 @@ describe("startTelegramWebhook", () => { secret: TELEGRAM_SECRET, }); expect(response.status).toBe(200); - expect(handlerSpy).toHaveBeenCalled(); + expect(handlerSpy).toHaveBeenCalledWith( + JSON.parse(payload), + expect.any(Function), + TELEGRAM_SECRET, + expect.any(Function), + ); + }, + ); + }); + + it("rejects unauthenticated requests before reading the request body", async () => { + handlerSpy.mockClear(); + await withStartedWebhook( + { + secret: TELEGRAM_SECRET, + path: TELEGRAM_WEBHOOK_PATH, + }, + async ({ port }) => { + const response = await postWebhookHeadersOnly({ + port, + path: TELEGRAM_WEBHOOK_PATH, + declaredLength: 1_024 * 1_024, + secret: "wrong-secret", + }); + + expect(response.statusCode).toBe(401); + expect(response.body).toBe("unauthorized"); + expect(handlerSpy).not.toHaveBeenCalled(); }, ); }); diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 1de38b1bb36..c049089a2ad 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { InputFile, webhookCallback } from "grammy"; import type { OpenClawConfig } from "../config/config.js"; @@ -74,6 +75,28 @@ async function initializeTelegramWebhookBot(params: { }); } +function resolveSingleHeaderValue(header: string | string[] | undefined): string | undefined { + if (typeof header === "string") { + return header; + } + if (Array.isArray(header) && header.length === 1) { + return header[0]; + } + return undefined; +} + +function hasValidTelegramWebhookSecret( + secretHeader: string | undefined, + expectedSecret: string, +): boolean { + if (typeof secretHeader !== "string") { + return false; + } + const actual = Buffer.from(secretHeader, "utf-8"); + const expected = Buffer.from(expectedSecret, "utf-8"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + export async function startTelegramWebhook(opts: { token: string; accountId?: string; @@ -147,6 +170,13 @@ export async function startTelegramWebhook(opts: { if (diagnosticsEnabled) { logWebhookReceived({ channel: "telegram", updateType: "telegram-post" }); } + const secretHeader = resolveSingleHeaderValue(req.headers["x-telegram-bot-api-secret-token"]); + if (!hasValidTelegramWebhookSecret(secretHeader, secret)) { + res.shouldKeepAlive = false; + res.setHeader("Connection", "close"); + respondText(401, "unauthorized"); + return; + } void (async () => { const body = await readJsonBodyWithLimit(req, { maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES, @@ -189,8 +219,6 @@ export async function startTelegramWebhook(opts: { replied = true; respondText(401, "unauthorized"); }; - const secretHeaderRaw = req.headers["x-telegram-bot-api-secret-token"]; - const secretHeader = Array.isArray(secretHeaderRaw) ? secretHeaderRaw[0] : secretHeaderRaw; await handler(body.value, reply, secretHeader, unauthorized); if (!replied) {