mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:30:43 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<typeof resolveTelegramSessionState>;
|
||||
let modelData: Awaited<ReturnType<typeof telegramDeps.buildModelsProviderData>>;
|
||||
try {
|
||||
|
||||
@@ -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<Parameters<typeof createTelegramBot>[0]["config"]>;
|
||||
|
||||
loadConfig.mockReturnValue(config);
|
||||
createTelegramBot({
|
||||
token: "tok",
|
||||
config,
|
||||
});
|
||||
const callbackHandler = onSpy.mock.calls.find(
|
||||
(call) => call[0] === "callback_query",
|
||||
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
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<Parameters<typeof createTelegramBot>[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<string, unknown>) => Promise<void>;
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user