mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-18 21:40:53 +00:00
fix(telegram): validate webhook secret before reading request body
Refs: GHSA-jq3f-vjww-8rq7
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user