fix(telegram): validate webhook secret before reading request body

Refs: GHSA-jq3f-vjww-8rq7
This commit is contained in:
Robin Waslander
2026-03-13 23:12:17 +01:00
parent 1ef0aa443b
commit 7e49e98f79
3 changed files with 123 additions and 3 deletions

View File

@@ -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.

View File

@@ -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();
},
);
});

View File

@@ -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) {