From 5b32c3138c3f0c9e1281775438cccc171b7e67de Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 22 Apr 2026 11:06:01 -0600 Subject: [PATCH] telegram: align model picker callback auth (#70235) * telegram: align model picker callback auth * docs(changelog): note telegram model callback auth fix * fix(telegram): use runtime config for model callback auth --- CHANGELOG.md | 1 + .../telegram/src/bot-handlers.runtime.ts | 92 +++++++++- extensions/telegram/src/bot.test.ts | 162 ++++++++++++++++++ 3 files changed, 254 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eafe24a059..2c4a35e51b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus. - Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state. - Security/dotenv: block workspace `.env` overrides for Matrix, Mattermost, IRC, and Synology endpoint settings so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. (#70240) Thanks @drobison00. +- Telegram: require the same `/models` authorization for group model-picker callbacks, so unauthorized participants can no longer browse or change the session model through inline buttons. (#70235) Thanks @drobison00. ## 2026.4.21 diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 951f120d30b..2d6c5a2cd92 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -6,6 +6,10 @@ import { resolveInboundDebounceMs, } from "openclaw/plugin-sdk/channel-inbound"; import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth"; +import { + resolveCommandAuthorization, + resolveCommandAuthorizedFromAuthorizers, +} from "openclaw/plugin-sdk/command-auth-native"; import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status"; import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { @@ -13,7 +17,7 @@ import { resolveSessionStoreEntry, updateSessionStore, } from "openclaw/plugin-sdk/config-runtime"; -import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; +import type { DmPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramGroupConfig, TelegramTopicConfig } from "openclaw/plugin-sdk/config-runtime"; import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime"; import { @@ -60,6 +64,7 @@ import { import { resolveMedia } from "./bot/delivery.js"; import { getTelegramTextParts, + buildTelegramGroupFrom, buildTelegramGroupPeerId, buildTelegramParentPeer, resolveTelegramForumFlag, @@ -783,6 +788,76 @@ export const registerTelegramHandlers = ({ return { allowed: true }; }; + const isTelegramModelCallbackAuthorized = (params: { + chatId: number; + isGroup: boolean; + senderId: string; + senderUsername: string; + context: TelegramEventAuthorizationContext; + cfg: OpenClawConfig; + }): boolean => { + const { chatId, isGroup, senderId, senderUsername, context, cfg } = params; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const dmAllowFrom = context.groupAllowOverride ?? allowFrom; + const commandsAllowFrom = cfg.commands?.allowFrom; + const commandsAllowFromConfigured = + commandsAllowFrom != null && + typeof commandsAllowFrom === "object" && + (Array.isArray(commandsAllowFrom.telegram) || Array.isArray(commandsAllowFrom["*"])); + if (commandsAllowFromConfigured) { + return resolveCommandAuthorization({ + ctx: { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + AccountId: accountId, + ChatType: isGroup ? "group" : "direct", + From: isGroup + ? buildTelegramGroupFrom(chatId, context.resolvedThreadId) + : `telegram:${chatId}`, + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + }, + cfg, + commandAuthorized: false, + }).isAuthorizedSender; + } + + const dmAllow = normalizeDmAllowFromWithStore({ + allowFrom: dmAllowFrom, + storeAllowFrom: isGroup ? [] : context.storeAllowFrom, + dmPolicy: context.dmPolicy, + }); + const senderAllowed = isSenderAllowed({ + allow: dmAllow, + senderId, + senderUsername, + }); + const groupSenderAllowed = isGroup + ? isSenderAllowed({ + allow: context.effectiveGroupAllow, + senderId, + senderUsername, + }) + : false; + + return resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { configured: dmAllow.hasEntries, allowed: senderAllowed }, + ...(isGroup + ? [ + { + configured: context.effectiveGroupAllow.hasEntries, + allowed: groupSenderAllowed, + }, + ] + : []), + ], + modeWhenAccessGroupsOff: "configured", + }); + }; + // Handle emoji reactions to messages. bot.on("message_reaction", async (ctx) => { try { @@ -1453,6 +1528,21 @@ export const registerTelegramHandlers = ({ // Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back) const modelCallback = parseModelCallbackData(data); if (modelCallback) { + if ( + !isTelegramModelCallbackAuthorized({ + chatId, + isGroup, + senderId, + senderUsername, + context: eventAuthContext, + cfg: runtimeCfg, + }) + ) { + logVerbose( + `Blocked telegram model callback from ${senderId || "unknown"} (not authorized for /models)`, + ); + return; + } let sessionState: ReturnType; let modelData: Awaited>; try { diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 46c228dd02d..bf7d024cd4a 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -223,6 +223,168 @@ describe("createTelegramBot", () => { } }); + it("blocks group model-selection callbacks for senders who are not authorized for /models", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageTextSpy.mockClear(); + + const storePath = `/tmp/openclaw-telegram-group-model-authz-${process.pid}-${Date.now()}.json`; + + await rm(storePath, { force: true }); + try { + const config = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + models: { + "anthropic/claude-opus-4-6": {}, + "openai/gpt-5.4": {}, + }, + }, + }, + commands: { + allowFrom: { + telegram: ["9"], + }, + }, + channels: { + telegram: { + dmPolicy: "open", + capabilities: { inlineButtons: "group" }, + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + session: { + store: storePath, + }, + } satisfies NonNullable[0]["config"]>; + + loadConfig.mockReturnValue(config); + createTelegramBot({ + token: "tok", + config, + }); + const callbackHandler = onSpy.mock.calls.find( + (call) => call[0] === "callback_query", + )?.[1] as (ctx: Record) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-group-model-authz-1", + data: "mdl_sel_openai/gpt-5.4", + from: { id: 999, first_name: "Mallory", username: "mallory" }, + message: { + chat: { id: -100999, type: "supergroup", title: "Test Group" }, + date: 1736380800, + message_id: 21, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(editMessageTextSpy).not.toHaveBeenCalled(); + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({}); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-1"); + } finally { + await rm(storePath, { force: true }); + } + }); + + it("recomputes group model-selection callback auth from runtime command config", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageTextSpy.mockClear(); + + const storePath = `/tmp/openclaw-telegram-group-model-authz-runtime-${process.pid}-${Date.now()}.json`; + + await rm(storePath, { force: true }); + try { + let currentConfig = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + models: { + "anthropic/claude-opus-4-6": {}, + "openai/gpt-5.4": {}, + }, + }, + }, + commands: { + allowFrom: { + telegram: ["999"], + }, + }, + channels: { + telegram: { + dmPolicy: "open", + capabilities: { inlineButtons: "group" }, + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + session: { + store: storePath, + }, + } satisfies NonNullable[0]["config"]>; + + loadConfig.mockImplementation(() => currentConfig); + createTelegramBot({ + token: "tok", + config: currentConfig, + }); + const callbackHandler = onSpy.mock.calls.find( + (call) => call[0] === "callback_query", + )?.[1] as (ctx: Record) => Promise; + expect(callbackHandler).toBeDefined(); + + currentConfig = { + ...currentConfig, + commands: { + allowFrom: { + telegram: ["9"], + }, + }, + }; + + await callbackHandler({ + callbackQuery: { + id: "cbq-group-model-authz-runtime-1", + data: "mdl_sel_openai/gpt-5.4", + from: { id: 999, first_name: "Mallory", username: "mallory" }, + message: { + chat: { id: -100999, type: "supergroup", title: "Test Group" }, + date: 1736380800, + message_id: 22, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(editMessageTextSpy).not.toHaveBeenCalled(); + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({}); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-runtime-1"); + } finally { + loadConfig.mockReset(); + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + await rm(storePath, { force: true }); + } + }); + it("allows callback_query in groups when group policy authorizes the sender", async () => { onSpy.mockClear(); editMessageTextSpy.mockClear();