From 6461eaff18469753fa242bd3eaeb33d2fa99e675 Mon Sep 17 00:00:00 2001 From: kinogram Date: Sat, 11 Apr 2026 23:32:43 +0800 Subject: [PATCH 1/3] fix: restrict Telegram bridge to allowlisted users --- README.md | 9 +++++ src/App.vue | 22 +++++++++-- src/api/codexGateway.ts | 4 ++ src/server/codexAppServerBridge.ts | 37 +++++++++++++++--- src/server/telegramThreadBridge.ts | 62 ++++++++++++++++++++++++++++++ tests.md | 29 ++++++++++++-- 6 files changed, 152 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7840429a..5f7d12af 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,19 @@ Set these environment variables before starting `codexapp`: ```bash export TELEGRAM_BOT_TOKEN="" +export TELEGRAM_ALLOWED_USER_IDS="," export TELEGRAM_DEFAULT_CWD="$PWD" # optional, defaults to current working directory npx codexapp ``` +`TELEGRAM_ALLOWED_USER_IDS` is required for safe access. Only allowlisted Telegram user IDs can use the bridge. If no allowed user IDs are configured, incoming Telegram messages are rejected. + +To find your Telegram user ID: + +1. Send a message to your bot. +2. Run `curl "https://api.telegram.org/bot/getUpdates"`. +3. Read `message.from.id` from the returned update payload. + Bot commands: - `/newthread` create and map a new Codex thread for this Telegram chat diff --git a/src/App.vue b/src/App.vue index 08d981a4..36f0db12 100644 --- a/src/App.vue +++ b/src/App.vue @@ -896,6 +896,7 @@ const telegramStatus = ref({ active: false, mappedChats: 0, mappedThreads: 0, + allowedUsers: 0, lastError: '', }) const mobileHiddenAtMs = ref(null) @@ -1164,7 +1165,7 @@ const contentStyle = computed(() => { const telegramStatusText = computed(() => { if (!telegramStatus.value.configured) return 'Not configured' const base = telegramStatus.value.active ? 'Online' : 'Configured (offline)' - const mapped = `${telegramStatus.value.mappedChats} chat(s), ${telegramStatus.value.mappedThreads} thread(s)` + const mapped = `${telegramStatus.value.mappedChats} chat(s), ${telegramStatus.value.mappedThreads} thread(s), ${telegramStatus.value.allowedUsers} allowed user(s)` const error = telegramStatus.value.lastError ? `, error: ${telegramStatus.value.lastError}` : '' return `${base}, ${mapped}${error}` }) @@ -1261,6 +1262,7 @@ async function refreshTelegramStatus(): Promise { active: false, mappedChats: 0, mappedThreads: 0, + allowedUsers: 0, lastError: message, } } @@ -1761,10 +1763,24 @@ function onConnectTelegramBot(): void { if (typeof window === 'undefined') return const botToken = window.prompt('Telegram bot token') if (!botToken || !botToken.trim()) return + const allowedUserIdsInput = window.prompt('Allowed Telegram user IDs (comma-separated)') + if (!allowedUserIdsInput || !allowedUserIdsInput.trim()) { + window.alert('At least one Telegram user ID is required.') + return + } + const allowedUserIds = Array.from(new Set(allowedUserIdsInput + .split(',') + .map((value) => value.trim().replace(/^(telegram|tg):/i, '').trim()) + .filter((value) => /^-?\d+$/.test(value)) + .map((value) => Number.parseInt(value, 10)))) + if (allowedUserIds.length === 0) { + window.alert('No valid Telegram user IDs were provided.') + return + } - void configureTelegramBot(botToken.trim()) + void configureTelegramBot(botToken.trim(), allowedUserIds) .then(() => { - window.alert('Telegram bot configured. Open the bot DM and send /start.') + window.alert('Telegram bot configured. Only allowlisted Telegram users can use the bridge.') void refreshTelegramStatus() }) .catch((error: unknown) => { diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index b9feb52a..6e0a8d72 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -121,6 +121,7 @@ export type TelegramStatus = { active: boolean mappedChats: number mappedThreads: number + allowedUsers: number lastError: string } @@ -1736,12 +1737,14 @@ export async function searchThreads( export async function configureTelegramBot( botToken: string, + allowedUserIds: number[], ): Promise { const response = await fetch('/codex-api/telegram/configure-bot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ botToken, + allowedUserIds, }), }) const payload = await response.json() @@ -1771,6 +1774,7 @@ export async function getTelegramStatus(): Promise { active: data.active === true, mappedChats: typeof data.mappedChats === 'number' ? data.mappedChats : 0, mappedThreads: typeof data.mappedThreads === 'number' ? data.mappedThreads : 0, + allowedUsers: typeof data.allowedUsers === 'number' ? data.allowedUsers : 0, lastError: typeof data.lastError === 'string' ? data.lastError : '', } } diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 84715e51..e8b801cc 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -1623,6 +1623,7 @@ let sessionIndexThreadTitleCacheState: SessionIndexThreadTitleCacheState = { type TelegramBridgeConfigState = { botToken: string chatIds: number[] + allowedUserIds: number[] } function normalizeThreadTitleCache(value: unknown): ThreadTitleCache { @@ -1877,13 +1878,26 @@ async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise function normalizeTelegramBridgeConfig(value: unknown): TelegramBridgeConfigState { const record = asRecord(value) - if (!record) return { botToken: '', chatIds: [] } + if (!record) return { botToken: '', chatIds: [], allowedUserIds: [] } const botToken = typeof record.botToken === 'string' ? record.botToken.trim() : '' const rawChatIds = Array.isArray(record.chatIds) ? record.chatIds : [] const chatIds = Array.from(new Set(rawChatIds .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) .map((value) => Math.trunc(value)))).slice(0, 50) - return { botToken, chatIds } + const rawAllowedUserIds = Array.isArray(record.allowedUserIds) ? record.allowedUserIds : [] + const allowedUserIds = Array.from(new Set(rawAllowedUserIds + .map((value) => { + if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value) + if (typeof value === 'string') { + const normalized = value.trim().replace(/^(telegram|tg):/i, '').trim() + if (/^-?\d+$/.test(normalized)) { + return Number.parseInt(normalized, 10) + } + } + return Number.NaN + }) + .filter((value) => Number.isFinite(value)))).slice(0, 100) + return { botToken, chatIds, allowedUserIds } } async function readTelegramBridgeConfig(): Promise { @@ -1893,7 +1907,7 @@ async function readTelegramBridgeConfig(): Promise { const payload = asRecord(JSON.parse(raw)) ?? {} return normalizeTelegramBridgeConfig(payload) } catch { - return { botToken: '', chatIds: [] } + return { botToken: '', chatIds: [], allowedUserIds: [] } } } @@ -1903,6 +1917,7 @@ async function writeTelegramBridgeConfig(nextState: TelegramBridgeConfigState): await writeFile(telegramConfigPath, JSON.stringify({ botToken: normalized.botToken, chatIds: normalized.chatIds, + allowedUserIds: normalized.allowedUserIds, }), 'utf8') } @@ -2830,6 +2845,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { .then((config) => { if (!config.botToken) return telegramBridge.configureToken(config.botToken) + telegramBridge.configureAllowedUserIds(config.allowedUserIds) telegramBridge.start() }) .catch(() => {}) @@ -3638,17 +3654,28 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { if (req.method === 'POST' && url.pathname === '/codex-api/telegram/configure-bot') { const payload = asRecord(await readJsonBody(req)) const botToken = typeof payload?.botToken === 'string' ? payload.botToken.trim() : '' + const rawAllowedUserIds = Array.isArray(payload?.allowedUserIds) ? payload.allowedUserIds : [] if (!botToken) { setJson(res, 400, { error: 'Missing botToken' }) return } + const config = normalizeTelegramBridgeConfig({ + botToken, + allowedUserIds: rawAllowedUserIds, + }) + if (config.allowedUserIds.length === 0) { + setJson(res, 400, { error: 'At least one allowed Telegram user ID is required' }) + return + } - telegramBridge.configureToken(botToken) + telegramBridge.configureToken(config.botToken) + telegramBridge.configureAllowedUserIds(config.allowedUserIds) telegramBridge.start() const existingConfig = await readTelegramBridgeConfig() await writeTelegramBridgeConfig({ - botToken, + botToken: config.botToken, chatIds: existingConfig.chatIds, + allowedUserIds: config.allowedUserIds, }) setJson(res, 200, { ok: true }) return diff --git a/src/server/telegramThreadBridge.ts b/src/server/telegramThreadBridge.ts index b3e4a756..45b57a21 100644 --- a/src/server/telegramThreadBridge.ts +++ b/src/server/telegramThreadBridge.ts @@ -5,6 +5,9 @@ type TelegramUpdate = { message?: { message_id?: number text?: string + from?: { + id?: number + } chat?: { id?: number } @@ -12,6 +15,9 @@ type TelegramUpdate = { callback_query?: { id?: string data?: string + from?: { + id?: number + } message?: { chat?: { id?: number @@ -34,6 +40,7 @@ export type TelegramBridgeStatus = { active: boolean mappedChats: number mappedThreads: number + allowedUsers: number lastError: string } @@ -62,10 +69,29 @@ function getErrorMessage(payload: unknown, fallback: string): string { return fallback } +function normalizeTelegramUserIds(values: unknown): number[] { + const rawValues = Array.isArray(values) ? values : [] + return Array.from(new Set(rawValues + .map((value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value) + } + if (typeof value === 'string' && value.trim().length > 0) { + const normalized = value.trim().replace(/^(telegram|tg):/i, '').trim() + if (/^-?\d+$/.test(normalized)) { + return Number.parseInt(normalized, 10) + } + } + return Number.NaN + }) + .filter((value) => Number.isFinite(value)))).slice(0, 100) +} + export class TelegramThreadBridge { private token: string private readonly appServer: AppServerLike private readonly defaultCwd: string + private allowedUserIds = new Set() private readonly threadIdByChatId = new Map() private readonly chatIdsByThreadId = new Map>() private readonly lastForwardedTurnByThreadId = new Map() @@ -79,6 +105,12 @@ export class TelegramThreadBridge { this.appServer = appServer this.token = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? '' this.defaultCwd = process.env.TELEGRAM_DEFAULT_CWD?.trim() ?? process.cwd() + this.allowedUserIds = new Set(normalizeTelegramUserIds( + (process.env.TELEGRAM_ALLOWED_USER_IDS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + )) this.onChatSeen = options.onChatSeen } @@ -151,10 +183,15 @@ export class TelegramThreadBridge { active: this.active, mappedChats: this.threadIdByChatId.size, mappedThreads: this.chatIdsByThreadId.size, + allowedUsers: this.allowedUserIds.size, lastError: this.lastError, } } + configureAllowedUserIds(allowedUserIds: unknown): void { + this.allowedUserIds = new Set(normalizeTelegramUserIds(allowedUserIds)) + } + connectThread(threadId: string, chatId: number, token?: string): void { const normalizedThreadId = threadId.trim() if (!normalizedThreadId) { @@ -217,8 +254,13 @@ export class TelegramThreadBridge { const message = update.message const chatId = message?.chat?.id + const senderId = message?.from?.id const text = message?.text?.trim() if (typeof chatId !== 'number' || !text) return + if (!this.isAllowedSender(senderId)) { + await this.sendTelegramMessage(chatId, this.unauthorizedMessage()) + return + } this.markChatSeen(chatId) if (text === '/start') { @@ -256,6 +298,16 @@ export class TelegramThreadBridge { const callbackId = typeof callbackQuery.id === 'string' ? callbackQuery.id : '' const data = typeof callbackQuery.data === 'string' ? callbackQuery.data : '' const chatId = callbackQuery.message?.chat?.id + const senderId = callbackQuery.from?.id + if (!this.isAllowedSender(senderId)) { + if (callbackId) { + await this.answerCallbackQuery(callbackId, 'Unauthorized sender') + } + if (typeof chatId === 'number') { + await this.sendTelegramMessage(chatId, this.unauthorizedMessage()) + } + return + } if (typeof chatId === 'number') { this.markChatSeen(chatId) } @@ -281,6 +333,16 @@ export class TelegramThreadBridge { } } + private isAllowedSender(senderId: unknown): senderId is number { + return typeof senderId === 'number' + && Number.isFinite(senderId) + && this.allowedUserIds.has(Math.trunc(senderId)) + } + + private unauthorizedMessage(): string { + return 'Unauthorized sender. Add this Telegram user ID to the bot allowlist before using the bridge.' + } + private async answerCallbackQuery(callbackQueryId: string, text: string): Promise { await fetch(this.apiUrl('answerCallbackQuery'), { method: 'POST', diff --git a/tests.md b/tests.md index 7be713ae..c103be9d 100644 --- a/tests.md +++ b/tests.md @@ -24,16 +24,18 @@ This file tracks manual regression and feature verification steps. #### Prerequisites - App server is running from this repository. - A valid Telegram bot token is available. +- At least one Telegram user ID is available for allowlisting. - Access to `~/.codex/` on the host machine. #### Steps -1. In the app UI, open Telegram connection and submit a bot token. +1. In the app UI, open Telegram connection and submit a bot token plus one or more allowed Telegram user IDs. 2. Verify file `~/.codex/telegram-bridge.json` exists. -3. Open `~/.codex/telegram-bridge.json` and confirm it contains a `botToken` field. +3. Open `~/.codex/telegram-bridge.json` and confirm it contains `botToken` and `allowedUserIds` fields. 4. Restart the app server and call Telegram status endpoint from UI to confirm it still reports configured. #### Expected Results - Telegram token is persisted in `~/.codex/telegram-bridge.json`. +- Telegram allowlisted user IDs are persisted in `~/.codex/telegram-bridge.json`. - Telegram bridge remains configured after restart. #### Rollback/Cleanup @@ -56,11 +58,32 @@ This file tracks manual regression and feature verification steps. #### Expected Results - `chatIds` is written after Telegram DM activity. - `chatIds` persists across bot reconfiguration. -- `botToken` and `chatIds` are both present in `~/.codex/telegram-bridge.json`. +- `botToken`, `chatIds`, and `allowedUserIds` are all present in `~/.codex/telegram-bridge.json`. #### Rollback/Cleanup - Remove `chatIds` or delete `~/.codex/telegram-bridge.json` to clear persisted chat targets. +### Feature: Telegram bridge rejects unauthorized senders + +#### Prerequisites +- App server is running from this repository. +- Telegram bot is configured with a known `allowedUserIds` entry. +- One Telegram account is allowlisted and one separate Telegram account is not. + +#### Steps +1. From the allowlisted Telegram account, send `/start` to the bot. +2. Confirm the bot responds normally. +3. From the non-allowlisted Telegram account, send `/start` to the same bot. +4. From the non-allowlisted account, send a normal text prompt. + +#### Expected Results +- The allowlisted account can use the Telegram bridge normally. +- The non-allowlisted account receives an unauthorized response. +- No thread is created or updated for the non-allowlisted account. + +#### Rollback/Cleanup +- Remove test chat mappings from `~/.codex/telegram-bridge.json` if needed. + ### Feature: Skills dropdown closes after selection in composer #### Prerequisites From cdcc94d52e46d7c4ff289051bff1ad12631ecfcb Mon Sep 17 00:00:00 2001 From: kinogram Date: Sun, 12 Apr 2026 00:23:02 +0800 Subject: [PATCH 2/3] feat: improve Telegram allowlist management --- src/App.vue | 230 ++++++++++++++++++++++++----- src/api/codexGateway.ts | 41 ++++- src/server/codexAppServerBridge.ts | 19 ++- src/server/telegramThreadBridge.ts | 45 ++++-- 4 files changed, 285 insertions(+), 50 deletions(-) diff --git a/src/App.vue b/src/App.vue index 36f0db12..9bf7422b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -68,9 +68,19 @@ @export-thread="onExportThread" /> -