From 4bfa9260ce8e7c3217b63f474aadfcd9c70ae61c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Apr 2026 18:36:27 +0900 Subject: [PATCH] fix(telegram): add dangerous private-network media opt-in --- CHANGELOG.md | 1 + docs/.generated/config-baseline.json | 28 +++++++++++++++ docs/channels/telegram.md | 21 ++++++++++- .../telegram/src/bot-handlers.buffers.ts | 15 ++++++-- .../telegram/src/bot-handlers.runtime.ts | 3 ++ .../bot/delivery.resolve-media-retry.test.ts | 35 +++++++++++-------- .../src/bot/delivery.resolve-media.ts | 11 ++++-- extensions/telegram/src/config-ui-hints.ts | 4 +++ ...ndled-channel-config-metadata.generated.ts | 14 ++++++++ src/config/types.telegram.ts | 6 ++++ src/config/zod-schema.providers-core.ts | 6 ++++ 11 files changed, 125 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b98cac390..53a75079c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Agents/sessions: release embedded runner session locks even when teardown cleanup throws, so timed-out or failed cleanup paths no longer leave sessions wedged until the stale-lock watchdog recovers them. (#59194) Thanks @samzong. - Slack/app manifest: add the missing `groups:read` scope to the onboarding and example Slack app manifest so apps copied from the OpenClaw templates can resolve private group conversations reliably. - Mobile pairing/Android: stop generating Tailscale and public mobile setup codes that point at unusable cleartext remote gateways, keep private LAN pairing allowed, and make Android reject insecure remote endpoints with clearer guidance while mixed bootstrap approvals honor operator scopes correctly. (#60128) Thanks @obviyus. +- Telegram/media: add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve `api.telegram.org` to private/internal/special-use addresses. ## 2026.4.2 diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 4d0918e4ce2..3cf601efbd7 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -47783,6 +47783,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.accounts.*.network.dangerouslyAllowPrivateNetwork", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.accounts.*.network.dnsResultOrder", "kind": "channel", @@ -49987,6 +49997,24 @@ "help": "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "hasChildren": false }, + { + "path": "channels.telegram.network.dangerouslyAllowPrivateNetwork", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access", + "advanced", + "channels", + "network", + "security" + ], + "label": "Telegram Dangerously Allow Private Network", + "help": "Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses.", + "hasChildren": false + }, { "path": "channels.telegram.network.dnsResultOrder", "kind": "channel", diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index b5f3e7ac5c6..e632ff7e3e1 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -914,6 +914,24 @@ channels: autoSelectFamily: false ``` + - If a trusted fake-IP or transparent proxy rewrites `api.telegram.org` to + private/internal/special-use addresses during media downloads, you can + opt in to the Telegram-only bypass: + +```yaml +channels: + telegram: + network: + dangerouslyAllowPrivateNetwork: true +``` + + + `channels.telegram.network.dangerouslyAllowPrivateNetwork` weakens Telegram + media SSRF protections. Use it only for trusted operator-controlled proxy + environments such as fake-IP routing. Leave it off for normal public + internet Telegram access. + + - Environment overrides (temporary): - `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1` - `OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY=1` @@ -980,6 +998,7 @@ Primary reference: - `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled. - `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+. +- `channels.telegram.network.dangerouslyAllowPrivateNetwork`: dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve `api.telegram.org` to private/internal/special-use addresses. - `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). @@ -1006,7 +1025,7 @@ Telegram-specific high-signal fields: - threading/replies: `replyToMode` - streaming: `streaming` (preview), `blockStreaming` - formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` -- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy` +- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy` - webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` - actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker` - reactions: `reactionNotifications`, `reactionLevel` diff --git a/extensions/telegram/src/bot-handlers.buffers.ts b/extensions/telegram/src/bot-handlers.buffers.ts index 455edc60b72..4655d2ae12c 100644 --- a/extensions/telegram/src/bot-handlers.buffers.ts +++ b/extensions/telegram/src/bot-handlers.buffers.ts @@ -6,6 +6,7 @@ import { } from "openclaw/plugin-sdk/channel-inbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; +import { mergeTelegramAccountConfig } from "./accounts.js"; import { hasInboundMedia, isRecoverableMediaGroupError, @@ -84,7 +85,9 @@ export function createTelegramInboundBufferRuntime(params: { runtime, telegramTransport, } = params; - const telegramCfg = cfg.channels?.telegram; + const telegramCfg = accountId + ? mergeTelegramAccountConfig(cfg, accountId) + : cfg.channels?.telegram; const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000; const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS = typeof opts.testTimings?.textFragmentGapMs === "number" && @@ -158,6 +161,7 @@ export function createTelegramInboundBufferRuntime(params: { opts.token, telegramTransport, telegramCfg?.apiRoot, + telegramCfg?.network?.dangerouslyAllowPrivateNetwork, ); if (!media) { return []; @@ -188,7 +192,14 @@ export function createTelegramInboundBufferRuntime(params: { for (const { ctx } of entry.messages) { let media; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport, telegramCfg?.apiRoot); + media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + telegramTransport, + telegramCfg?.apiRoot, + telegramCfg?.network?.dangerouslyAllowPrivateNetwork, + ); } catch (mediaErr) { if (!isRecoverableMediaGroupError(mediaErr)) { throw mediaErr; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 44811117c26..4e3b0a0f55a 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -387,6 +387,7 @@ export const registerTelegramHandlers = ({ opts.token, telegramTransport, telegramCfg.apiRoot, + telegramCfg.network?.dangerouslyAllowPrivateNetwork, ); } catch (mediaErr) { if (!isRecoverableMediaGroupError(mediaErr)) { @@ -495,6 +496,7 @@ export const registerTelegramHandlers = ({ opts.token, telegramTransport, telegramCfg.apiRoot, + telegramCfg.network?.dangerouslyAllowPrivateNetwork, ); if (!media) { return []; @@ -1019,6 +1021,7 @@ export const registerTelegramHandlers = ({ opts.token, telegramTransport, telegramCfg.apiRoot, + telegramCfg.network?.dangerouslyAllowPrivateNetwork, ); } catch (mediaErr) { if (isMediaSizeLimitError(mediaErr)) { diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index aa145983ed8..770e8f50eea 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -546,19 +546,32 @@ describe("resolveMedia original filename preservation", () => { expect(result).not.toBeNull(); }); + it("opts into private-network Telegram media downloads only when explicitly configured", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); + mockPdfFetchAndSave("file_42.pdf"); + + const ctx = makeCtx("document", getFile); + const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, undefined, true); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + ssrfPolicy: { + hostnameAllowlist: ["api.telegram.org"], + allowPrivateNetwork: true, + allowRfc2544BenchmarkRange: true, + }, + }), + ); + expect(result).not.toBeNull(); + }); + it("constructs correct download URL with custom apiRoot for documents", async () => { const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); mockPdfFetchAndSave("file_42.pdf"); const customApiRoot = "http://192.168.1.50:8081/custom-bot-api"; const ctx = makeCtx("document", getFile); - const result = await resolveMedia( - ctx, - MAX_MEDIA_BYTES, - BOT_TOKEN, - undefined, - customApiRoot, - ); + const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, customApiRoot); // Verify the URL uses the custom apiRoot, not the default Telegram API expect(fetchRemoteMedia).toHaveBeenCalledWith( @@ -583,13 +596,7 @@ describe("resolveMedia original filename preservation", () => { const customApiRoot = "http://localhost:8081/bot"; const ctx = makeCtx("sticker", getFile); - const result = await resolveMedia( - ctx, - MAX_MEDIA_BYTES, - BOT_TOKEN, - undefined, - customApiRoot, - ); + const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, customApiRoot); // Verify the URL uses the custom apiRoot for sticker downloads expect(fetchRemoteMedia).toHaveBeenCalledWith( diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index e5ed40e6eb2..00d377b9929 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -18,7 +18,7 @@ const FILE_TOO_BIG_RE = /file is too big/i; const GrammyErrorCtor: typeof GrammyError | undefined = typeof GrammyError === "function" ? GrammyError : undefined; -function buildTelegramMediaSsrfPolicy(apiRoot?: string) { +function buildTelegramMediaSsrfPolicy(apiRoot?: string, dangerouslyAllowPrivateNetwork?: boolean) { const hostnames = ["api.telegram.org"]; let allowedHostnames: string[] | undefined; if (apiRoot) { @@ -41,6 +41,7 @@ function buildTelegramMediaSsrfPolicy(apiRoot?: string) { // enforcing SSRF checks on the resolved and redirected targets. hostnameAllowlist: hostnames, ...(allowedHostnames ? { allowedHostnames } : {}), + ...(dangerouslyAllowPrivateNetwork ? { allowPrivateNetwork: true } : {}), allowRfc2544BenchmarkRange: true, }; } @@ -169,6 +170,7 @@ async function downloadAndSaveTelegramFile(params: { telegramFileName?: string; mimeType?: string; apiRoot?: string; + dangerouslyAllowPrivateNetwork?: boolean; }) { if (path.isAbsolute(params.filePath)) { return { path: params.filePath, contentType: params.mimeType }; @@ -183,7 +185,7 @@ async function downloadAndSaveTelegramFile(params: { filePathHint: params.filePath, maxBytes: params.maxBytes, readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, - ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot), + ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot, params.dangerouslyAllowPrivateNetwork), }); const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath; return saveMediaBuffer( @@ -202,6 +204,7 @@ async function resolveStickerMedia(params: { token: string; transport?: TelegramTransport; apiRoot?: string; + dangerouslyAllowPrivateNetwork?: boolean; }): Promise< | { path: string; @@ -243,6 +246,7 @@ async function resolveStickerMedia(params: { transport: resolvedTransport, maxBytes, apiRoot: params.apiRoot, + dangerouslyAllowPrivateNetwork: params.dangerouslyAllowPrivateNetwork, }); // Check sticker cache for existing description @@ -299,6 +303,7 @@ export async function resolveMedia( token: string, transport?: TelegramTransport, apiRoot?: string, + dangerouslyAllowPrivateNetwork?: boolean, ): Promise<{ path: string; contentType?: string; @@ -313,6 +318,7 @@ export async function resolveMedia( token, transport, apiRoot, + dangerouslyAllowPrivateNetwork, }); if (stickerResolved !== undefined) { return stickerResolved; @@ -339,6 +345,7 @@ export async function resolveMedia( telegramFileName: metadata.fileName, mimeType: metadata.mimeType, apiRoot, + dangerouslyAllowPrivateNetwork, }); const placeholder = resolveTelegramMediaPlaceholder(msg) ?? ""; return { path: saved.path, contentType: saved.contentType, placeholder }; diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index dee9c8e9490..cca9ff294d0 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -53,6 +53,10 @@ export const telegramChannelConfigUiHints = { label: "Telegram autoSelectFamily", help: "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", }, + "network.dangerouslyAllowPrivateNetwork": { + label: "Telegram Dangerously Allow Private Network", + help: "Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses.", + }, timeoutSeconds: { label: "Telegram API Timeout (seconds)", help: "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index a04ee4d9147..bd109e74047 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -12400,6 +12400,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["ipv4first", "verbatim"], }, + dangerouslyAllowPrivateNetwork: { + description: + "Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.", + type: "boolean", + }, }, additionalProperties: false, }, @@ -13396,6 +13401,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["ipv4first", "verbatim"], }, + dangerouslyAllowPrivateNetwork: { + description: + "Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.", + type: "boolean", + }, }, additionalProperties: false, }, @@ -13691,6 +13701,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "Telegram autoSelectFamily", help: "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", }, + "network.dangerouslyAllowPrivateNetwork": { + label: "Telegram Dangerously Allow Private Network", + help: "Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses.", + }, timeoutSeconds: { label: "Telegram API Timeout (seconds)", help: "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 2c6b0e5450e..aa99b934e2c 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -53,6 +53,12 @@ export type TelegramNetworkConfig = { * Default: "ipv4first" on Node 22+ to avoid common fetch failures. */ dnsResultOrder?: "ipv4first" | "verbatim"; + /** + * Dangerous opt-in for Telegram media downloads in trusted fake-IP or + * transparent-proxy environments that resolve api.telegram.org to + * private/internal/special-use addresses. + */ + dangerouslyAllowPrivateNetwork?: boolean; }; export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 69629340b03..930827a9816 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -245,6 +245,12 @@ export const TelegramAccountSchemaBase = z .object({ autoSelectFamily: z.boolean().optional(), dnsResultOrder: z.enum(["ipv4first", "verbatim"]).optional(), + dangerouslyAllowPrivateNetwork: z + .boolean() + .optional() + .describe( + "Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.", + ), }) .strict() .optional(),