OpenClaw has a Telegram webhook request forgery (missing `channels.telegram.webhookSecret`) → auth bypass
Description
OpenClaw is a personal AI assistant. In versions 2026.1.30 and below, if channels.telegram.webhookSecret is not set when in Telegram webhook mode, OpenClaw may accept webhook HTTP requests without verifying Telegram’s secret token header. In deployments where the webhook endpoint is reachable by an attacker, this can allow forged Telegram updates (for example spoofing message.from.id). If an attacker can reach the webhook endpoint, they may be able to send forged updates that are processed as if they came from Telegram. Depending on enabled commands/tools and configuration, this could lead to unintended bot actions. Note: Telegram webhook mode is not enabled by default. It is enabled only when channels.telegram.webhookUrl is configured. This issue has been fixed in version 2026.2.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.1 | 2026.2.1 |
Affected products
1Patches
43cbcba10cf30fix(security): enforce bounded webhook body handling
20 files changed · +834 −281
extensions/bluebubbles/src/monitor.ts+41 −56 modified@@ -2,11 +2,14 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, + isRequestBodyLimitError, logAckFailure, logInboundDrop, logTypingFailure, + readRequestBodyWithLimit, resolveAckReaction, resolveControlCommandGate, + requestBodyErrorToText, } from "openclaw/plugin-sdk"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js"; @@ -511,63 +514,40 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v } async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) { - const chunks: Buffer[] = []; - let total = 0; - return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { - let done = false; - const finish = (result: { ok: boolean; value?: unknown; error?: string }) => { - if (done) { - return; - } - done = true; - clearTimeout(timer); - resolve(result); - }; - - const timer = setTimeout(() => { - finish({ ok: false, error: "request body timeout" }); - req.destroy(); - }, timeoutMs); + let rawBody = ""; + try { + rawBody = await readRequestBodyWithLimit(req, { maxBytes, timeoutMs }); + } catch (error) { + if (isRequestBodyLimitError(error, "PAYLOAD_TOO_LARGE")) { + return { ok: false, error: "payload too large" }; + } + if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) { + return { ok: false, error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }; + } + if (isRequestBodyLimitError(error, "CONNECTION_CLOSED")) { + return { ok: false, error: requestBodyErrorToText("CONNECTION_CLOSED") }; + } + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } - req.on("data", (chunk: Buffer) => { - total += chunk.length; - if (total > maxBytes) { - finish({ ok: false, error: "payload too large" }); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - try { - const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw.trim()) { - finish({ ok: false, error: "empty payload" }); - return; - } - try { - finish({ ok: true, value: JSON.parse(raw) as unknown }); - return; - } catch { - const params = new URLSearchParams(raw); - const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); - if (payload) { - finish({ ok: true, value: JSON.parse(payload) as unknown }); - return; - } - throw new Error("invalid json"); - } - } catch (err) { - finish({ ok: false, error: err instanceof Error ? err.message : String(err) }); + try { + const raw = rawBody.toString(); + if (!raw.trim()) { + return { ok: false, error: "empty payload" }; + } + try { + return { ok: true, value: JSON.parse(raw) as unknown }; + } catch { + const params = new URLSearchParams(raw); + const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); + if (payload) { + return { ok: true, value: JSON.parse(payload) as unknown }; } - }); - req.on("error", (err) => { - finish({ ok: false, error: err instanceof Error ? err.message : String(err) }); - }); - req.on("close", () => { - finish({ ok: false, error: "connection closed" }); - }); - }); + throw new Error("invalid json"); + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } } function asRecord(value: unknown): Record<string, unknown> | null { @@ -1461,7 +1441,12 @@ export async function handleBlueBubblesWebhookRequest( const body = await readJsonBody(req, 1024 * 1024); if (!body.ok) { - res.statusCode = body.error === "payload too large" ? 413 : 400; + res.statusCode = + body.error === "payload too large" + ? 413 + : body.error === requestBodyErrorToText("REQUEST_BODY_TIMEOUT") + ? 408 + : 400; res.end(body.error ?? "invalid payload"); console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`); return true;
extensions/feishu/src/monitor.ts+28 −2 modified@@ -1,6 +1,11 @@ -import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk"; import * as Lark from "@larksuiteoapi/node-sdk"; import * as http from "http"; +import { + type ClawdbotConfig, + type RuntimeEnv, + type HistoryEntry, + installRequestBodyLimitGuard, +} from "openclaw/plugin-sdk"; import type { ResolvedFeishuAccount } from "./types.js"; import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; @@ -18,6 +23,8 @@ export type MonitorFeishuOpts = { const wsClients = new Map<string, Lark.WSClient>(); const httpServers = new Map<string, http.Server>(); const botOpenIds = new Map<string, string>(); +const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000; async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> { try { @@ -197,7 +204,26 @@ async function monitorWebhook({ log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`); const server = http.createServer(); - server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true })); + const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }); + server.on("request", (req, res) => { + const guard = installRequestBodyLimitGuard(req, res, { + maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES, + timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS, + responseFormat: "text", + }); + if (guard.isTripped()) { + return; + } + void Promise.resolve(webhookHandler(req, res)) + .catch((err) => { + if (!guard.isTripped()) { + error(`feishu[${accountId}]: webhook handler error: ${String(err)}`); + } + }) + .finally(() => { + guard.dispose(); + }); + }); httpServers.set(accountId, server); return new Promise((resolve, reject) => {
extensions/googlechat/src/monitor.ts+18 −44 modified@@ -1,6 +1,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions, resolveMentionGatingWithBypass } from "openclaw/plugin-sdk"; +import { + createReplyPrefixOptions, + readJsonBodyWithLimit, + requestBodyErrorToText, + resolveMentionGatingWithBypass, +} from "openclaw/plugin-sdk"; import type { GoogleChatAnnotation, GoogleChatAttachment, @@ -84,46 +89,6 @@ function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | return "/googlechat"; } -async function readJsonBody(req: IncomingMessage, maxBytes: number) { - const chunks: Buffer[] = []; - let total = 0; - return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { - let resolved = false; - const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => { - if (resolved) { - return; - } - resolved = true; - req.removeAllListeners(); - resolve(value); - }; - req.on("data", (chunk: Buffer) => { - total += chunk.length; - if (total > maxBytes) { - doResolve({ ok: false, error: "payload too large" }); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - try { - const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw.trim()) { - doResolve({ ok: false, error: "empty payload" }); - return; - } - doResolve({ ok: true, value: JSON.parse(raw) as unknown }); - } catch (err) { - doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); - } - }); - req.on("error", (err) => { - doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); - }); - }); -} - export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { const key = normalizeWebhookPath(target.path); const normalizedTarget = { ...target, path: key }; @@ -178,10 +143,19 @@ export async function handleGoogleChatWebhookRequest( ? authHeader.slice("bearer ".length) : ""; - const body = await readJsonBody(req, 1024 * 1024); + const body = await readJsonBodyWithLimit(req, { + maxBytes: 1024 * 1024, + timeoutMs: 30_000, + emptyObjectOnEmpty: false, + }); if (!body.ok) { - res.statusCode = body.error === "payload too large" ? 413 : 400; - res.end(body.error ?? "invalid payload"); + res.statusCode = + body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; + res.end( + body.code === "REQUEST_BODY_TIMEOUT" + ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") + : body.error, + ); return true; }
extensions/msteams/src/monitor.ts+11 −1 modified@@ -1,5 +1,6 @@ import type { Request, Response } from "express"; import { + DEFAULT_WEBHOOK_MAX_BODY_BYTES, mergeAllowlist, summarizeMapping, type OpenClawConfig, @@ -32,6 +33,8 @@ export type MonitorMSTeamsResult = { shutdown: () => Promise<void>; }; +const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES; + export async function monitorMSTeamsProvider( opts: MonitorMSTeamsOpts, ): Promise<MonitorMSTeamsResult> { @@ -239,7 +242,14 @@ export async function monitorMSTeamsProvider( // Create Express server const expressApp = express.default(); - expressApp.use(express.json()); + expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES })); + expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => { + if (err && typeof err === "object" && "status" in err && err.status === 413) { + res.status(413).json({ error: "Payload too large" }); + return; + } + next(err); + }); expressApp.use(authorizeJWT(authConfig)); // Set up the messages endpoint - use configured path and /api/messages as fallback
extensions/nextcloud-talk/src/monitor.read-body.test.ts+38 −0 added@@ -0,0 +1,38 @@ +import type { IncomingMessage } from "node:http"; +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { readNextcloudTalkWebhookBody } from "./monitor.js"; + +function createMockRequest(chunks: string[]): IncomingMessage { + const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void }; + req.destroyed = false; + req.headers = {}; + req.destroy = () => { + req.destroyed = true; + }; + + void Promise.resolve().then(() => { + for (const chunk of chunks) { + req.emit("data", Buffer.from(chunk, "utf-8")); + if (req.destroyed) { + return; + } + } + req.emit("end"); + }); + + return req; +} + +describe("readNextcloudTalkWebhookBody", () => { + it("reads valid body within max bytes", async () => { + const req = createMockRequest(['{"type":"Create"}']); + const body = await readNextcloudTalkWebhookBody(req, 1024); + expect(body).toBe('{"type":"Create"}'); + }); + + it("rejects when payload exceeds max bytes", async () => { + const req = createMockRequest(["x".repeat(300)]); + await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge"); + }); +});
extensions/nextcloud-talk/src/monitor.ts+36 −8 modified@@ -1,5 +1,10 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { + type RuntimeEnv, + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "openclaw/plugin-sdk"; import type { CoreConfig, NextcloudTalkInboundMessage, @@ -14,6 +19,8 @@ import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./sig const DEFAULT_WEBHOOK_PORT = 8788; const DEFAULT_WEBHOOK_HOST = "0.0.0.0"; const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; +const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const HEALTH_PATH = "/healthz"; function formatError(err: unknown): string { @@ -62,12 +69,13 @@ function payloadToInboundMessage( }; } -function readBody(req: IncomingMessage): Promise<string> { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); - req.on("error", reject); +export function readNextcloudTalkWebhookBody( + req: IncomingMessage, + maxBodyBytes: number, +): Promise<string> { + return readRequestBodyWithLimit(req, { + maxBytes: maxBodyBytes, + timeoutMs: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS, }); } @@ -77,6 +85,12 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe stop: () => void; } { const { port, host, path, secret, onMessage, onError, abortSignal } = opts; + const maxBodyBytes = + typeof opts.maxBodyBytes === "number" && + Number.isFinite(opts.maxBodyBytes) && + opts.maxBodyBytes > 0 + ? Math.floor(opts.maxBodyBytes) + : DEFAULT_WEBHOOK_MAX_BODY_BYTES; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) { @@ -92,7 +106,7 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try { - const body = await readBody(req); + const body = await readNextcloudTalkWebhookBody(req, maxBodyBytes); const headers = extractNextcloudTalkHeaders( req.headers as Record<string, string | string[] | undefined>, @@ -140,6 +154,20 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe onError?.(err instanceof Error ? err : new Error(formatError(err))); } } catch (err) { + if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { + if (!res.headersSent) { + res.writeHead(413, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Payload too large" })); + } + return; + } + if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { + if (!res.headersSent) { + res.writeHead(408, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); + } + return; + } const error = err instanceof Error ? err : new Error(formatError(err)); onError?.(error); if (!res.headersSent) {
extensions/nextcloud-talk/src/types.ts+1 −0 modified@@ -168,6 +168,7 @@ export type NextcloudTalkWebhookServerOptions = { host: string; path: string; secret: string; + maxBodyBytes?: number; onMessage: (message: NextcloudTalkInboundMessage) => void | Promise<void>; onError?: (error: Error) => void; abortSignal?: AbortSignal;
extensions/nostr/src/nostr-profile-http.ts+18 −47 modified@@ -8,6 +8,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { readJsonBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk"; import { z } from "zod"; import { publishNostrProfile, getNostrProfileState } from "./channel.js"; import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; @@ -234,54 +235,24 @@ async function readJsonBody( maxBytes = 64 * 1024, timeoutMs = 30_000, ): Promise<unknown> { - return new Promise((resolve, reject) => { - let done = false; - const finish = (fn: () => void) => { - if (done) { - return; - } - done = true; - clearTimeout(timer); - fn(); - }; - - const timer = setTimeout(() => { - finish(() => { - const err = new Error("Request body timeout"); - req.destroy(err); - reject(err); - }); - }, timeoutMs); - - const chunks: Buffer[] = []; - let totalBytes = 0; - - req.on("data", (chunk: Buffer) => { - totalBytes += chunk.length; - if (totalBytes > maxBytes) { - finish(() => { - reject(new Error("Request body too large")); - req.destroy(); - }); - return; - } - chunks.push(chunk); - }); - - req.on("end", () => { - finish(() => { - try { - const body = Buffer.concat(chunks).toString("utf-8"); - resolve(body ? JSON.parse(body) : {}); - } catch { - reject(new Error("Invalid JSON")); - } - }); - }); - - req.on("error", (err) => finish(() => reject(err))); - req.on("close", () => finish(() => reject(new Error("Connection closed")))); + const result = await readJsonBodyWithLimit(req, { + maxBytes, + timeoutMs, + emptyObjectOnEmpty: true, }); + if (result.ok) { + return result.value; + } + if (result.code === "PAYLOAD_TOO_LARGE") { + throw new Error("Request body too large"); + } + if (result.code === "REQUEST_BODY_TIMEOUT") { + throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT")); + } + if (result.code === "CONNECTION_CLOSED") { + throw new Error(requestBodyErrorToText("CONNECTION_CLOSED")); + } + throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error); } function parseAccountIdFromPath(pathname: string): string | null {
extensions/voice-call/src/webhook.ts+12 −37 modified@@ -1,6 +1,11 @@ import { spawn } from "node:child_process"; import http from "node:http"; import { URL } from "node:url"; +import { + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "openclaw/plugin-sdk"; import type { VoiceCallConfig } from "./config.js"; import type { CoreConfig } from "./core-bridge.js"; import type { CallManager } from "./manager.js"; @@ -244,11 +249,16 @@ export class VoiceCallWebhookServer { try { body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES); } catch (err) { - if (err instanceof Error && err.message === "PayloadTooLarge") { + if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { res.statusCode = 413; res.end("Payload Too Large"); return; } + if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { + res.statusCode = 408; + res.end(requestBodyErrorToText("REQUEST_BODY_TIMEOUT")); + return; + } throw err; } @@ -303,42 +313,7 @@ export class VoiceCallWebhookServer { maxBytes: number, timeoutMs = 30_000, ): Promise<string> { - return new Promise((resolve, reject) => { - let done = false; - const finish = (fn: () => void) => { - if (done) { - return; - } - done = true; - clearTimeout(timer); - fn(); - }; - - const timer = setTimeout(() => { - finish(() => { - const err = new Error("Request body timeout"); - req.destroy(err); - reject(err); - }); - }, timeoutMs); - - const chunks: Buffer[] = []; - let totalBytes = 0; - req.on("data", (chunk: Buffer) => { - totalBytes += chunk.length; - if (totalBytes > maxBytes) { - finish(() => { - req.destroy(); - reject(new Error("PayloadTooLarge")); - }); - return; - } - chunks.push(chunk); - }); - req.on("end", () => finish(() => resolve(Buffer.concat(chunks).toString("utf-8")))); - req.on("error", (err) => finish(() => reject(err))); - req.on("close", () => finish(() => reject(new Error("Connection closed")))); - }); + return readRequestBodyWithLimit(req, { maxBytes, timeoutMs }); } /**
extensions/zalo/src/monitor.ts+17 −35 modified@@ -1,6 +1,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; +import { + createReplyPrefixOptions, + readJsonBodyWithLimit, + requestBodyErrorToText, +} from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, @@ -61,37 +65,6 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { }); } -async function readJsonBody(req: IncomingMessage, maxBytes: number) { - const chunks: Buffer[] = []; - let total = 0; - return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { - req.on("data", (chunk: Buffer) => { - total += chunk.length; - if (total > maxBytes) { - resolve({ ok: false, error: "payload too large" }); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - try { - const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw.trim()) { - resolve({ ok: false, error: "empty payload" }); - return; - } - resolve({ ok: true, value: JSON.parse(raw) as unknown }); - } catch (err) { - resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); - } - }); - req.on("error", (err) => { - resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); - }); - }); -} - type WebhookTarget = { token: string; account: ResolvedZaloAccount; @@ -177,10 +150,19 @@ export async function handleZaloWebhookRequest( return true; } - const body = await readJsonBody(req, 1024 * 1024); + const body = await readJsonBodyWithLimit(req, { + maxBytes: 1024 * 1024, + timeoutMs: 30_000, + emptyObjectOnEmpty: false, + }); if (!body.ok) { - res.statusCode = body.error === "payload too large" ? 413 : 400; - res.end(body.error ?? "invalid payload"); + res.statusCode = + body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; + res.end( + body.code === "REQUEST_BODY_TIMEOUT" + ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") + : body.error, + ); return true; }
src/gateway/hooks.ts+15 −42 modified@@ -4,6 +4,7 @@ import type { ChannelId } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; +import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; @@ -177,48 +178,20 @@ export async function readJsonBody( req: IncomingMessage, maxBytes: number, ): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { - return await new Promise((resolve) => { - let done = false; - let total = 0; - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => { - if (done) { - return; - } - total += chunk.length; - if (total > maxBytes) { - done = true; - resolve({ ok: false, error: "payload too large" }); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - if (done) { - return; - } - done = true; - const raw = Buffer.concat(chunks).toString("utf-8").trim(); - if (!raw) { - resolve({ ok: true, value: {} }); - return; - } - try { - const parsed = JSON.parse(raw) as unknown; - resolve({ ok: true, value: parsed }); - } catch (err) { - resolve({ ok: false, error: String(err) }); - } - }); - req.on("error", (err) => { - if (done) { - return; - } - done = true; - resolve({ ok: false, error: String(err) }); - }); - }); + const result = await readJsonBodyWithLimit(req, { maxBytes, emptyObjectOnEmpty: true }); + if (result.ok) { + return result; + } + if (result.code === "PAYLOAD_TOO_LARGE") { + return { ok: false, error: "payload too large" }; + } + if (result.code === "REQUEST_BODY_TIMEOUT") { + return { ok: false, error: "request body timeout" }; + } + if (result.code === "CONNECTION_CLOSED") { + return { ok: false, error: requestBodyErrorToText("CONNECTION_CLOSED") }; + } + return { ok: false, error: result.error }; } export function normalizeHookHeaders(req: IncomingMessage) {
src/gateway/http-common.ts+12 −0 modified@@ -58,6 +58,18 @@ export async function readJsonBodyOrError( ): Promise<unknown> { const body = await readJsonBody(req, maxBytes); if (!body.ok) { + if (body.error === "payload too large") { + sendJson(res, 413, { + error: { message: "Payload too large", type: "invalid_request_error" }, + }); + return undefined; + } + if (body.error === "request body timeout") { + sendJson(res, 408, { + error: { message: "Request body timeout", type: "invalid_request_error" }, + }); + return undefined; + } sendInvalidRequest(res, body.error); return undefined; }
src/gateway/server-http.ts+6 −1 modified@@ -287,7 +287,12 @@ export function createHooksRequestHandler( const body = await readJsonBody(req, hooksConfig.maxBodyBytes); if (!body.ok) { - const status = body.error === "payload too large" ? 413 : 400; + const status = + body.error === "payload too large" + ? 413 + : body.error === "request body timeout" + ? 408 + : 400; sendJson(res, status, { ok: false, error: body.error }); return true; }
src/infra/http-body.test.ts+116 −0 added@@ -0,0 +1,116 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { + installRequestBodyLimitGuard, + isRequestBodyLimitError, + readJsonBodyWithLimit, + readRequestBodyWithLimit, +} from "./http-body.js"; + +function createMockRequest(params: { + chunks?: string[]; + headers?: Record<string, string>; + emitEnd?: boolean; +}): IncomingMessage { + const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void }; + req.destroyed = false; + req.headers = params.headers ?? {}; + req.destroy = () => { + req.destroyed = true; + }; + + if (params.chunks) { + void Promise.resolve().then(() => { + for (const chunk of params.chunks ?? []) { + req.emit("data", Buffer.from(chunk, "utf-8")); + if (req.destroyed) { + return; + } + } + if (params.emitEnd !== false) { + req.emit("end"); + } + }); + } + + return req; +} + +function createMockResponse(): ServerResponse & { body?: string } { + const headers: Record<string, string> = {}; + const res = { + headersSent: false, + statusCode: 200, + setHeader: (key: string, value: string) => { + headers[key.toLowerCase()] = value; + return res; + }, + end: (body?: string) => { + res.headersSent = true; + res.body = body; + return res; + }, + } as unknown as ServerResponse & { body?: string }; + return res; +} + +describe("http body limits", () => { + it("reads body within max bytes", async () => { + const req = createMockRequest({ chunks: ['{"ok":true}'] }); + await expect(readRequestBodyWithLimit(req, { maxBytes: 1024 })).resolves.toBe('{"ok":true}'); + }); + + it("rejects oversized body", async () => { + const req = createMockRequest({ chunks: ["x".repeat(512)] }); + await expect(readRequestBodyWithLimit(req, { maxBytes: 64 })).rejects.toMatchObject({ + message: "PayloadTooLarge", + }); + }); + + it("returns json parse error when body is invalid", async () => { + const req = createMockRequest({ chunks: ["{bad json"] }); + const result = await readJsonBodyWithLimit(req, { maxBytes: 1024, emptyObjectOnEmpty: false }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe("INVALID_JSON"); + } + }); + + it("returns payload-too-large for json body", async () => { + const req = createMockRequest({ chunks: ["x".repeat(1024)] }); + const result = await readJsonBodyWithLimit(req, { maxBytes: 10 }); + expect(result).toEqual({ ok: false, code: "PAYLOAD_TOO_LARGE", error: "Payload too large" }); + }); + + it("guard rejects oversized declared content-length", () => { + const req = createMockRequest({ + headers: { "content-length": "9999" }, + emitEnd: false, + }); + const res = createMockResponse(); + const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128 }); + expect(guard.isTripped()).toBe(true); + expect(guard.code()).toBe("PAYLOAD_TOO_LARGE"); + expect(res.statusCode).toBe(413); + }); + + it("guard rejects streamed oversized body", async () => { + const req = createMockRequest({ chunks: ["small", "x".repeat(256)], emitEnd: false }); + const res = createMockResponse(); + const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128, responseFormat: "text" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(guard.isTripped()).toBe(true); + expect(guard.code()).toBe("PAYLOAD_TOO_LARGE"); + expect(res.statusCode).toBe(413); + expect(res.body).toBe("Payload too large"); + }); + + it("timeout surfaces typed error", async () => { + const req = createMockRequest({ emitEnd: false }); + const promise = readRequestBodyWithLimit(req, { maxBytes: 128, timeoutMs: 10 }); + await expect(promise).rejects.toSatisfy((error: unknown) => + isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT"), + ); + }); +});
src/infra/http-body.ts+347 −0 added@@ -0,0 +1,347 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +export const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; + +export type RequestBodyLimitErrorCode = + | "PAYLOAD_TOO_LARGE" + | "REQUEST_BODY_TIMEOUT" + | "CONNECTION_CLOSED"; + +type RequestBodyLimitErrorInit = { + code: RequestBodyLimitErrorCode; + message?: string; +}; + +const DEFAULT_ERROR_MESSAGE: Record<RequestBodyLimitErrorCode, string> = { + PAYLOAD_TOO_LARGE: "PayloadTooLarge", + REQUEST_BODY_TIMEOUT: "RequestBodyTimeout", + CONNECTION_CLOSED: "RequestBodyConnectionClosed", +}; + +const DEFAULT_ERROR_STATUS_CODE: Record<RequestBodyLimitErrorCode, number> = { + PAYLOAD_TOO_LARGE: 413, + REQUEST_BODY_TIMEOUT: 408, + CONNECTION_CLOSED: 400, +}; + +const DEFAULT_RESPONSE_MESSAGE: Record<RequestBodyLimitErrorCode, string> = { + PAYLOAD_TOO_LARGE: "Payload too large", + REQUEST_BODY_TIMEOUT: "Request body timeout", + CONNECTION_CLOSED: "Connection closed", +}; + +export class RequestBodyLimitError extends Error { + readonly code: RequestBodyLimitErrorCode; + readonly statusCode: number; + + constructor(init: RequestBodyLimitErrorInit) { + super(init.message ?? DEFAULT_ERROR_MESSAGE[init.code]); + this.name = "RequestBodyLimitError"; + this.code = init.code; + this.statusCode = DEFAULT_ERROR_STATUS_CODE[init.code]; + } +} + +export function isRequestBodyLimitError( + error: unknown, + code?: RequestBodyLimitErrorCode, +): error is RequestBodyLimitError { + if (!(error instanceof RequestBodyLimitError)) { + return false; + } + if (!code) { + return true; + } + return error.code === code; +} + +export function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string { + return DEFAULT_RESPONSE_MESSAGE[code]; +} + +function parseContentLengthHeader(req: IncomingMessage): number | null { + const header = req.headers["content-length"]; + const raw = Array.isArray(header) ? header[0] : header; + if (typeof raw !== "string") { + return null; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +export type ReadRequestBodyOptions = { + maxBytes: number; + timeoutMs?: number; + encoding?: BufferEncoding; +}; + +export async function readRequestBodyWithLimit( + req: IncomingMessage, + options: ReadRequestBodyOptions, +): Promise<string> { + const maxBytes = Number.isFinite(options.maxBytes) + ? Math.max(1, Math.floor(options.maxBytes)) + : 1; + const timeoutMs = + typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) + ? Math.max(1, Math.floor(options.timeoutMs)) + : DEFAULT_WEBHOOK_BODY_TIMEOUT_MS; + const encoding = options.encoding ?? "utf-8"; + + const declaredLength = parseContentLengthHeader(req); + if (declaredLength !== null && declaredLength > maxBytes) { + const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }); + if (!req.destroyed) { + req.destroy(error); + } + throw error; + } + + return await new Promise((resolve, reject) => { + let done = false; + let ended = false; + let totalBytes = 0; + const chunks: Buffer[] = []; + + const cleanup = () => { + req.removeListener("data", onData); + req.removeListener("end", onEnd); + req.removeListener("error", onError); + req.removeListener("close", onClose); + clearTimeout(timer); + }; + + const finish = (cb: () => void) => { + if (done) { + return; + } + done = true; + cleanup(); + cb(); + }; + + const fail = (error: RequestBodyLimitError | Error) => { + finish(() => reject(error)); + }; + + const timer = setTimeout(() => { + const error = new RequestBodyLimitError({ code: "REQUEST_BODY_TIMEOUT" }); + if (!req.destroyed) { + req.destroy(error); + } + fail(error); + }, timeoutMs); + + const onData = (chunk: Buffer | string) => { + if (done) { + return; + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > maxBytes) { + const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }); + if (!req.destroyed) { + req.destroy(error); + } + fail(error); + return; + } + chunks.push(buffer); + }; + + const onEnd = () => { + ended = true; + finish(() => resolve(Buffer.concat(chunks).toString(encoding))); + }; + + const onError = (error: Error) => { + if (done) { + return; + } + fail(error); + }; + + const onClose = () => { + if (done || ended) { + return; + } + fail(new RequestBodyLimitError({ code: "CONNECTION_CLOSED" })); + }; + + req.on("data", onData); + req.on("end", onEnd); + req.on("error", onError); + req.on("close", onClose); + }); +} + +export type ReadJsonBodyResult = + | { ok: true; value: unknown } + | { ok: false; error: string; code: RequestBodyLimitErrorCode | "INVALID_JSON" }; + +export type ReadJsonBodyOptions = ReadRequestBodyOptions & { + emptyObjectOnEmpty?: boolean; +}; + +export async function readJsonBodyWithLimit( + req: IncomingMessage, + options: ReadJsonBodyOptions, +): Promise<ReadJsonBodyResult> { + try { + const raw = await readRequestBodyWithLimit(req, options); + const trimmed = raw.trim(); + if (!trimmed) { + if (options.emptyObjectOnEmpty === false) { + return { ok: false, code: "INVALID_JSON", error: "empty payload" }; + } + return { ok: true, value: {} }; + } + try { + return { ok: true, value: JSON.parse(trimmed) as unknown }; + } catch (error) { + return { + ok: false, + code: "INVALID_JSON", + error: error instanceof Error ? error.message : String(error), + }; + } + } catch (error) { + if (isRequestBodyLimitError(error)) { + return { ok: false, code: error.code, error: requestBodyErrorToText(error.code) }; + } + return { + ok: false, + code: "INVALID_JSON", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export type RequestBodyLimitGuard = { + dispose: () => void; + isTripped: () => boolean; + code: () => RequestBodyLimitErrorCode | null; +}; + +export type RequestBodyLimitGuardOptions = { + maxBytes: number; + timeoutMs?: number; + responseFormat?: "json" | "text"; + responseText?: Partial<Record<RequestBodyLimitErrorCode, string>>; +}; + +export function installRequestBodyLimitGuard( + req: IncomingMessage, + res: ServerResponse, + options: RequestBodyLimitGuardOptions, +): RequestBodyLimitGuard { + const maxBytes = Number.isFinite(options.maxBytes) + ? Math.max(1, Math.floor(options.maxBytes)) + : 1; + const timeoutMs = + typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) + ? Math.max(1, Math.floor(options.timeoutMs)) + : DEFAULT_WEBHOOK_BODY_TIMEOUT_MS; + const responseFormat = options.responseFormat ?? "json"; + const customText = options.responseText ?? {}; + + let tripped = false; + let reason: RequestBodyLimitErrorCode | null = null; + let done = false; + let ended = false; + let totalBytes = 0; + + const cleanup = () => { + req.removeListener("data", onData); + req.removeListener("end", onEnd); + req.removeListener("close", onClose); + req.removeListener("error", onError); + clearTimeout(timer); + }; + + const finish = () => { + if (done) { + return; + } + done = true; + cleanup(); + }; + + const respond = (error: RequestBodyLimitError) => { + const text = customText[error.code] ?? requestBodyErrorToText(error.code); + if (!res.headersSent) { + res.statusCode = error.statusCode; + if (responseFormat === "text") { + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(text); + } else { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ error: text })); + } + } + }; + + const trip = (error: RequestBodyLimitError) => { + if (tripped) { + return; + } + tripped = true; + reason = error.code; + finish(); + respond(error); + if (!req.destroyed) { + req.destroy(error); + } + }; + + const onData = (chunk: Buffer | string) => { + if (done) { + return; + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > maxBytes) { + trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" })); + } + }; + + const onEnd = () => { + ended = true; + finish(); + }; + + const onClose = () => { + if (done || ended) { + return; + } + finish(); + }; + + const onError = () => { + finish(); + }; + + const timer = setTimeout(() => { + trip(new RequestBodyLimitError({ code: "REQUEST_BODY_TIMEOUT" })); + }, timeoutMs); + + req.on("data", onData); + req.on("end", onEnd); + req.on("close", onClose); + req.on("error", onError); + + const declaredLength = parseContentLengthHeader(req); + if (declaredLength !== null && declaredLength > maxBytes) { + trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" })); + } + + return { + dispose: finish, + isTripped: () => tripped, + code: () => reason, + }; +}
src/line/monitor.read-body.test.ts+38 −0 added@@ -0,0 +1,38 @@ +import type { IncomingMessage } from "node:http"; +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { readLineWebhookRequestBody } from "./monitor.js"; + +function createMockRequest(chunks: string[]): IncomingMessage { + const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void }; + req.destroyed = false; + req.headers = {}; + req.destroy = () => { + req.destroyed = true; + }; + + void Promise.resolve().then(() => { + for (const chunk of chunks) { + req.emit("data", Buffer.from(chunk, "utf-8")); + if (req.destroyed) { + return; + } + } + req.emit("end"); + }); + + return req; +} + +describe("readLineWebhookRequestBody", () => { + it("reads body within limit", async () => { + const req = createMockRequest(['{"events":[{"type":"message"}]}']); + const body = await readLineWebhookRequestBody(req, 1024); + expect(body).toContain('"events"'); + }); + + it("rejects oversized body", async () => { + const req = createMockRequest(["x".repeat(2048)]); + await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge"); + }); +});
src/line/monitor.ts+28 −7 modified@@ -7,6 +7,11 @@ import { chunkMarkdownText } from "../auto-reply/chunk.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import { danger, logVerbose } from "../globals.js"; +import { + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "../infra/http-body.js"; import { normalizePluginHttpPath } from "../plugins/http-path.js"; import { registerPluginHttpRoute } from "../plugins/http-registry.js"; import { deliverLineAutoReply } from "./auto-reply-delivery.js"; @@ -46,6 +51,9 @@ export interface LineProviderMonitor { stop: () => void; } +const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000; + // Track runtime state in memory (simplified version) const runtimeState = new Map< string, @@ -85,12 +93,13 @@ export function getLineRuntimeState(accountId: string) { return runtimeState.get(`line:${accountId}`); } -async function readRequestBody(req: IncomingMessage): Promise<string> { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); - req.on("error", reject); +export async function readLineWebhookRequestBody( + req: IncomingMessage, + maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES, +): Promise<string> { + return await readRequestBodyWithLimit(req, { + maxBytes, + timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS, }); } @@ -310,7 +319,7 @@ export async function monitorLineProvider( } try { - const rawBody = await readRequestBody(req); + const rawBody = await readLineWebhookRequestBody(req, LINE_WEBHOOK_MAX_BODY_BYTES); const signature = req.headers["x-line-signature"]; // Validate signature @@ -346,6 +355,18 @@ export async function monitorLineProvider( }); } } catch (err) { + if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { + res.statusCode = 413; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Payload too large" })); + return; + } + if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { + res.statusCode = 408; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); + return; + } runtime.error?.(danger(`line webhook error: ${String(err)}`)); if (!res.headersSent) { res.statusCode = 500;
src/plugin-sdk/index.ts+10 −0 modified@@ -136,6 +136,16 @@ export { rejectDevicePairing, } from "../infra/device-pairing.js"; export { formatErrorMessage } from "../infra/errors.js"; +export { + DEFAULT_WEBHOOK_BODY_TIMEOUT_MS, + DEFAULT_WEBHOOK_MAX_BODY_BYTES, + RequestBodyLimitError, + installRequestBodyLimitGuard, + isRequestBodyLimitError, + readJsonBodyWithLimit, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "../infra/http-body.js"; export { isWSLSync, isWSL2Sync, isWSLEnv } from "../infra/wsl.js"; export { isTruthyEnvValue } from "../infra/env.js"; export { resolveToolsBySender } from "../config/group-policy.js";
src/slack/monitor/provider.ts+22 −1 modified@@ -8,6 +8,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; import { mergeAllowlist, summarizeMapping } from "../../channels/allowlists/resolve-utils.js"; import { loadConfig } from "../../config/config.js"; import { warn } from "../../globals.js"; +import { installRequestBodyLimitGuard } from "../../infra/http-body.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveSlackAccount } from "../accounts.js"; import { resolveSlackWebClientOptions } from "../client.js"; @@ -30,6 +31,10 @@ const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & { const slackBolt = (slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule; const { App, HTTPReceiver } = slackBolt; + +const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000; + function parseApiAppIdFromAppToken(raw?: string) { const token = raw?.trim(); if (!token) { @@ -146,7 +151,23 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const slackHttpHandler = slackMode === "http" && receiver ? async (req: IncomingMessage, res: ServerResponse) => { - await Promise.resolve(receiver.requestListener(req, res)); + const guard = installRequestBodyLimitGuard(req, res, { + maxBytes: SLACK_WEBHOOK_MAX_BODY_BYTES, + timeoutMs: SLACK_WEBHOOK_BODY_TIMEOUT_MS, + responseFormat: "text", + }); + if (guard.isTripped()) { + return; + } + try { + await Promise.resolve(receiver.requestListener(req, res)); + } catch (err) { + if (!guard.isTripped()) { + throw err; + } + } finally { + guard.dispose(); + } } : null; let unregisterHttpHandler: (() => void) | null = null;
src/telegram/webhook.ts+20 −0 modified@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { installRequestBodyLimitGuard } from "../infra/http-body.js"; import { logWebhookError, logWebhookProcessed, @@ -16,6 +17,9 @@ import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; +const TELEGRAM_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS = 30_000; + export async function startTelegramWebhook(opts: { token: string; accountId?: string; @@ -66,6 +70,14 @@ export async function startTelegramWebhook(opts: { if (diagnosticsEnabled) { logWebhookReceived({ channel: "telegram", updateType: "telegram-post" }); } + const guard = installRequestBodyLimitGuard(req, res, { + maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES, + timeoutMs: TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS, + responseFormat: "text", + }); + if (guard.isTripped()) { + return; + } const handled = handler(req, res); if (handled && typeof handled.catch === "function") { void handled @@ -79,6 +91,9 @@ export async function startTelegramWebhook(opts: { } }) .catch((err) => { + if (guard.isTripped()) { + return; + } const errMsg = formatErrorMessage(err); if (diagnosticsEnabled) { logWebhookError({ @@ -92,8 +107,13 @@ export async function startTelegramWebhook(opts: { res.writeHead(500); } res.end(); + }) + .finally(() => { + guard.dispose(); }); + return; } + guard.dispose(); }); const publicUrl =
5643a934799dfix(security): default standalone servers to loopback bind (#13184)
10 files changed · +85 −5
CHANGELOG.md+1 −0 modified@@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai - CI: Implement pipeline and workflow order. Thanks @quotentiroler. - WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez. - Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov. +- Security/Telegram: breaking default-behavior change — standalone canvas host + Telegram webhook listeners now bind loopback (`127.0.0.1`) instead of `0.0.0.0`; set `channels.telegram.webhookHost` when external ingress is required. (#13184) Thanks @davidrudduck. - Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620) - Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow. - Discord: cap gateway reconnect attempts to avoid infinite retry loops. (#12230) Thanks @Yida-Dev.
docs/channels/grammy.md+1 −1 modified@@ -20,7 +20,7 @@ title: grammY - **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls). - **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel. -- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`. +- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`. - **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
docs/channels/telegram.md+43 −2 modified@@ -595,10 +595,12 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates" - set `channels.telegram.webhookUrl` - set `channels.telegram.webhookSecret` (required when webhook URL is set) - optional `channels.telegram.webhookPath` (default `/telegram-webhook`) + - optional `channels.telegram.webhookHost` (default `127.0.0.1`) - Default local listener for webhook mode binds to `0.0.0.0:8787`. + Default local listener for webhook mode binds to `127.0.0.1:8787`. If your public endpoint differs, place a reverse proxy in front and point `webhookUrl` at the public URL. + Set `webhookHost` (for example `0.0.0.0`) when you intentionally need external ingress. </Accordion> @@ -673,6 +675,45 @@ More help: [Channel troubleshooting](/channels/troubleshooting). Primary reference: +- `channels.telegram.enabled`: enable/disable channel startup. +- `channels.telegram.botToken`: bot token (BotFather). +- `channels.telegram.tokenFile`: read token from file path. +- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). +- `channels.telegram.allowFrom`: DM allowlist (ids/usernames). `open` requires `"*"`. +- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.telegram.groupAllowFrom`: group sender allowlist (ids/usernames). +- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults). + - `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`). + - `channels.telegram.groups.<id>.requireMention`: mention gating default. + - `channels.telegram.groups.<id>.skills`: skill filter (omit = all skills, empty = none). + - `channels.telegram.groups.<id>.allowFrom`: per-group sender allowlist override. + - `channels.telegram.groups.<id>.systemPrompt`: extra system prompt for the group. + - `channels.telegram.groups.<id>.enabled`: disable the group when `false`. + - `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group). + - `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). + - `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override. +- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). +- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override. +- `channels.telegram.replyToMode`: `off | first | all` (default: `first`). +- `channels.telegram.textChunkLimit`: outbound chunk size (chars). +- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. +- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). +- `channels.telegram.streamMode`: `off | partial | block` (draft streaming). +- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). +- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). +- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. +- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). +- `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`). +- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set). +- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`). +- `channels.telegram.webhookHost`: local webhook bind host (default `127.0.0.1`). +- `channels.telegram.actions.reactions`: gate Telegram tool reactions. +- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. +- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. +- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false). +- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set). +- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set). + - [Configuration reference - Telegram](/gateway/configuration-reference#telegram) Telegram-specific high-signal fields: @@ -684,7 +725,7 @@ Telegram-specific high-signal fields: - streaming: `streamMode`, `draftChunk`, `blockStreaming` - formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` - media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy` -- webhook: `webhookUrl`, `webhookSecret`, `webhookPath` +- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` - actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker` - reactions: `reactionNotifications`, `reactionLevel` - writes/history: `configWrites`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
extensions/telegram/src/channel.ts+1 −0 modified@@ -414,6 +414,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb webhookUrl: account.config.webhookUrl, webhookSecret: account.config.webhookSecret, webhookPath: account.config.webhookPath, + webhookHost: account.config.webhookHost, }); }, logoutAccount: async ({ accountId, cfg }) => {
src/canvas-host/server.ts+1 −1 modified@@ -449,7 +449,7 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva })); const ownsHandler = opts.ownsHandler ?? opts.handler === undefined; - const bindHost = opts.listenHost?.trim() || "0.0.0.0"; + const bindHost = opts.listenHost?.trim() || "127.0.0.1"; const server: Server = http.createServer((req, res) => { if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") { return;
src/config/types.telegram.ts+2 −0 modified@@ -109,6 +109,8 @@ export type TelegramAccountConfig = { webhookUrl?: string; webhookSecret?: string; webhookPath?: string; + /** Local webhook listener bind host (default: 127.0.0.1). */ + webhookHost?: string; /** Per-action tool gating (default: true for all). */ actions?: TelegramActionConfig; /**
src/config/zod-schema.providers-core.ts+1 −0 modified@@ -127,6 +127,7 @@ export const TelegramAccountSchemaBase = z webhookUrl: z.string().optional(), webhookSecret: z.string().optional().register(sensitive), webhookPath: z.string().optional(), + webhookHost: z.string().optional(), actions: z .object({ reactions: z.boolean().optional(),
src/telegram/monitor.test.ts+32 −0 modified@@ -38,6 +38,9 @@ const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({ computeBackoff: vi.fn(() => 0), sleepWithAbort: vi.fn(async () => undefined), })); +const { startTelegramWebhookSpy } = vi.hoisted(() => ({ + startTelegramWebhookSpy: vi.fn(async () => ({ server: { close: vi.fn() }, stop: vi.fn() })), +})); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal<typeof import("../config/config.js")>(); @@ -83,6 +86,10 @@ vi.mock("../infra/backoff.js", () => ({ sleepWithAbort, })); +vi.mock("./webhook.js", () => ({ + startTelegramWebhook: (...args: unknown[]) => startTelegramWebhookSpy(...args), +})); + vi.mock("../auto-reply/reply.js", () => ({ getReplyFromConfig: async (ctx: { Body?: string }) => ({ text: `echo:${ctx.Body}`, @@ -99,6 +106,7 @@ describe("monitorTelegramProvider (grammY)", () => { runSpy.mockClear(); computeBackoff.mockClear(); sleepWithAbort.mockClear(); + startTelegramWebhookSpy.mockClear(); }); it("processes a DM and sends reply", async () => { @@ -187,4 +195,28 @@ describe("monitorTelegramProvider (grammY)", () => { await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token"); }); + + it("passes configured webhookHost to webhook listener", async () => { + await monitorTelegramProvider({ + token: "tok", + useWebhook: true, + webhookUrl: "https://example.test/telegram", + webhookSecret: "secret", + config: { + agents: { defaults: { maxConcurrent: 2 } }, + channels: { + telegram: { + webhookHost: "0.0.0.0", + }, + }, + }, + }); + + expect(startTelegramWebhookSpy).toHaveBeenCalledWith( + expect.objectContaining({ + host: "0.0.0.0", + }), + ); + expect(runSpy).not.toHaveBeenCalled(); + }); });
src/telegram/monitor.ts+2 −0 modified@@ -25,6 +25,7 @@ export type MonitorTelegramOpts = { webhookPath?: string; webhookPort?: number; webhookSecret?: string; + webhookHost?: string; proxyFetch?: typeof fetch; webhookUrl?: string; }; @@ -158,6 +159,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { path: opts.webhookPath, port: opts.webhookPort, secret: opts.webhookSecret, + host: opts.webhookHost ?? account.config.webhookHost, runtime: opts.runtime as RuntimeEnv, fetch: proxyFetch, abortSignal: opts.abortSignal,
src/telegram/webhook.ts+1 −1 modified@@ -33,7 +33,7 @@ export async function startTelegramWebhook(opts: { const path = opts.path ?? "/telegram-webhook"; const healthPath = opts.healthPath ?? "/healthz"; const port = opts.port ?? 8787; - const host = opts.host ?? "0.0.0.0"; + const host = opts.host ?? "127.0.0.1"; const runtime = opts.runtime ?? defaultRuntime; const diagnosticsEnabled = isDiagnosticsEnabled(opts.config); const bot = createTelegramBot({
633fe8b9c17ffix(aa-08): apply security fix
2 files changed · +19 −2
src/telegram/webhook.test.ts+10 −0 modified@@ -31,6 +31,7 @@ describe("startTelegramWebhook", () => { const cfg = { bindings: [] }; const { server } = await startTelegramWebhook({ token: "tok", + secret: "secret", accountId: "opie", config: cfg, port: 0, // random free port @@ -62,6 +63,7 @@ describe("startTelegramWebhook", () => { const cfg = { bindings: [] }; const { server } = await startTelegramWebhook({ token: "tok", + secret: "secret", accountId: "opie", config: cfg, port: 0, @@ -82,4 +84,12 @@ describe("startTelegramWebhook", () => { expect(handlerSpy).toHaveBeenCalled(); abort.abort(); }); + + it("rejects startup when webhook secret is missing", async () => { + await expect( + startTelegramWebhook({ + token: "tok", + }), + ).rejects.toThrow(/requires a non-empty secret token/i); + }); });
src/telegram/webhook.ts+9 −2 modified@@ -38,6 +38,13 @@ export async function startTelegramWebhook(opts: { const healthPath = opts.healthPath ?? "/healthz"; const port = opts.port ?? 8787; const host = opts.host ?? "127.0.0.1"; + const secret = typeof opts.secret === "string" ? opts.secret.trim() : ""; + if (!secret) { + throw new Error( + "Telegram webhook mode requires a non-empty secret token. " + + "Set channels.telegram.webhookSecret in your config.", + ); + } const runtime = opts.runtime ?? defaultRuntime; const diagnosticsEnabled = isDiagnosticsEnabled(opts.config); const bot = createTelegramBot({ @@ -48,7 +55,7 @@ export async function startTelegramWebhook(opts: { accountId: opts.accountId, }); const handler = webhookCallback(bot, "http", { - secretToken: opts.secret, + secretToken: secret, }); if (diagnosticsEnabled) { @@ -124,7 +131,7 @@ export async function startTelegramWebhook(opts: { runtime, fn: () => bot.api.setWebhook(publicUrl, { - secret_token: opts.secret, + secret_token: secret, allowed_updates: resolveTelegramAllowedUpdates(), }), });
ca92597e1f95Merge commit from fork
6 files changed · +108 −6
docs/channels/grammy.md+1 −1 modified@@ -18,7 +18,7 @@ title: grammY - **Single client path:** fetch-based implementation removed; grammY is now the sole Telegram client (send + gateway) with the grammY throttler enabled by default. - **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`. - **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. -- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls). +- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls). - **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel. - **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`. - **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
docs/channels/telegram.md+3 −3 modified@@ -395,7 +395,7 @@ Most users want: `groupPolicy: "allowlist"` + `groupAllowFrom` + specific groups ## Long-polling vs webhook - Default: long-polling (no public URL required). -- Webhook mode: set `channels.telegram.webhookUrl` (optionally `channels.telegram.webhookSecret` + `channels.telegram.webhookPath`). +- Webhook mode: set `channels.telegram.webhookUrl` and `channels.telegram.webhookSecret` (optionally `channels.telegram.webhookPath`). - The local listener binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default. - If your public URL is different, use a reverse proxy and point `channels.telegram.webhookUrl` at the public endpoint. @@ -732,8 +732,8 @@ Provider options: - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. - `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). -- `channels.telegram.webhookUrl`: enable webhook mode. -- `channels.telegram.webhookSecret`: webhook secret (optional). +- `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`). +- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set). - `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`). - `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
docs/gateway/configuration.md+1 −1 modified@@ -1091,7 +1091,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w autoSelectFamily: false, }, proxy: "socks5://localhost:9050", - webhookUrl: "https://example.com/telegram-webhook", + webhookUrl: "https://example.com/telegram-webhook", // requires webhookSecret webhookSecret: "secret", webhookPath: "/telegram-webhook", },
README.md+1 −1 modified@@ -340,7 +340,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker ### [Telegram](https://docs.openclaw.ai/channels/telegram) - Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins). -- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed. +- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed. ```json5 {
src/config/telegram-webhook-secret.test.ts+65 −0 added@@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { validateConfigObject } from "./config.js"; + +describe("Telegram webhook config", () => { + it("accepts webhookUrl when webhookSecret is configured", () => { + const res = validateConfigObject({ + channels: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + webhookSecret: "secret", + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects webhookUrl without webhookSecret", () => { + const res = validateConfigObject({ + channels: { + telegram: { + webhookUrl: "https://example.com/telegram-webhook", + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.telegram.webhookSecret"); + } + }); + + it("accepts account webhookUrl when base webhookSecret is configured", () => { + const res = validateConfigObject({ + channels: { + telegram: { + webhookSecret: "secret", + accounts: { + ops: { + webhookUrl: "https://example.com/telegram-webhook", + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects account webhookUrl without webhookSecret", () => { + const res = validateConfigObject({ + channels: { + telegram: { + accounts: { + ops: { + webhookUrl: "https://example.com/telegram-webhook", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.telegram.accounts.ops.webhookSecret"); + } + }); +});
src/config/zod-schema.providers-core.ts+37 −0 modified@@ -164,6 +164,43 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ 'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"', }); validateTelegramCustomCommands(value, ctx); + + const baseWebhookUrl = typeof value.webhookUrl === "string" ? value.webhookUrl.trim() : ""; + const baseWebhookSecret = + typeof value.webhookSecret === "string" ? value.webhookSecret.trim() : ""; + if (baseWebhookUrl && !baseWebhookSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "channels.telegram.webhookUrl requires channels.telegram.webhookSecret", + path: ["webhookSecret"], + }); + } + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + if (account.enabled === false) { + continue; + } + const accountWebhookUrl = + typeof account.webhookUrl === "string" ? account.webhookUrl.trim() : ""; + if (!accountWebhookUrl) { + continue; + } + const accountSecret = + typeof account.webhookSecret === "string" ? account.webhookSecret.trim() : ""; + if (!accountSecret && !baseWebhookSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "channels.telegram.accounts.*.webhookUrl requires channels.telegram.webhookSecret or channels.telegram.accounts.*.webhookSecret", + path: ["accounts", accountId, "webhookSecret"], + }); + } + } }); export const DiscordDmSchema = z
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-mp5h-m6qj-6292ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25474ghsaADVISORY
- github.com/openclaw/openclaw/commit/3cbcba10cf30c2ffb898f0d8c7dfb929f15f8930ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/commit/5643a934799dc523ec2ef18c007e1aa2c386b670ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/commit/633fe8b9c17f02fcc68ecdb5ec212a5ace932f09ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/commit/ca92597e1f9593236ad86810b66633144b69314dghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.1ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-mp5h-m6qj-6292ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.