From 5643a934799dc523ec2ef18c007e1aa2c386b670 Mon Sep 17 00:00:00 2001 From: David Rudduck <47308254+davidrudduck@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:39:56 +1000 Subject: [PATCH] fix(security): default standalone servers to loopback bind (#13184) * fix(security): default standalone servers to loopback bind (#4) Change canvas host and telegram webhook default bind from 0.0.0.0 (all interfaces) to 127.0.0.1 (loopback only) to prevent unintended network exposure when no explicit host is configured. * fix: restore telegram webhook host override while keeping loopback defaults (openclaw#13184) thanks @davidrudduck * style: format telegram docs after rebase (openclaw#13184) thanks @davidrudduck --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/channels/grammy.md | 2 +- docs/channels/telegram.md | 45 +++++++++++++++++++++++-- extensions/telegram/src/channel.ts | 1 + src/canvas-host/server.ts | 2 +- src/config/types.telegram.ts | 2 ++ src/config/zod-schema.providers-core.ts | 1 + src/telegram/monitor.test.ts | 32 ++++++++++++++++++ src/telegram/monitor.ts | 2 ++ src/telegram/webhook.ts | 2 +- 10 files changed, 85 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4366445a..d3d4d48d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/grammy.md b/docs/channels/grammy.md index 1b73394ef7..c2891d1a2e 100644 --- a/docs/channels/grammy.md +++ b/docs/channels/grammy.md @@ -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::`); groups use `agent::telegram:group:`; 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. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 0e7537ac5d..7a2b57102c 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -595,10 +595,12 @@ curl "https://api.telegram.org/bot/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. @@ -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..groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`). + - `channels.telegram.groups..requireMention`: mention gating default. + - `channels.telegram.groups..skills`: skill filter (omit = all skills, empty = none). + - `channels.telegram.groups..allowFrom`: per-group sender allowlist override. + - `channels.telegram.groups..systemPrompt`: extra system prompt for the group. + - `channels.telegram.groups..enabled`: disable the group when `false`. + - `channels.telegram.groups..topics..*`: per-topic overrides (same fields as group). + - `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). + - `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. +- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). +- `channels.telegram.accounts..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` diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0b9800be65..d996add77b 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -414,6 +414,7 @@ export const telegramPlugin: ChannelPlugin { diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 1ba3bc78ff..a4df38fe12 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -449,7 +449,7 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise { if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") { return; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index fcad3154ed..32f9d7044a 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -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; /** diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index c377beecd7..590accc9c6 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 20ffd4e1bc..bc36774d3d 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -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(); @@ -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(); + }); }); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 0905c43558..f5f015d024 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -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, diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index b9dc070d18..83c6f9afc7 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -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({