From 56b6b45bef8c87171e6e666372b02aede9c31927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 8 Jul 2026 16:57:32 +0800 Subject: [PATCH] fix(security): harden telegram and desktop guards Tested: bun test adapters/telegram/__tests__/commands.test.ts Tested: cd desktop && bun test electron/services/navigationGuards.test.ts Tested: bun run check:adapters Tested: bun run check:desktop Not-tested: bun run verify (scoped local hardening, not PR-ready validation) Confidence: high Scope-risk: narrow --- adapters/telegram/__tests__/commands.test.ts | 19 +++++++++ adapters/telegram/commands.ts | 39 +++++++++++++------ adapters/telegram/index.ts | 17 ++++---- .../services/navigationGuards.test.ts | 18 ++++++++- desktop/electron/services/navigationGuards.ts | 36 ++++++++++++++++- 5 files changed, 106 insertions(+), 23 deletions(-) diff --git a/adapters/telegram/__tests__/commands.test.ts b/adapters/telegram/__tests__/commands.test.ts index f6d76d55..30875e79 100644 --- a/adapters/telegram/__tests__/commands.test.ts +++ b/adapters/telegram/__tests__/commands.test.ts @@ -5,6 +5,7 @@ import { buildProviderSelectionItems, createTelegramCommandController, createTelegramRuntimeCommandController, + registerAuthorizedTelegramCommand, registerTelegramExtendedCommands, renderSelectionView, sessionToSelectionItem, @@ -245,6 +246,24 @@ describe('Telegram command controller helpers', () => { .toBe(false) }) + it('guards directly registered commands before running side effects', async () => { + const handlers = new Map['ctx']) => unknown>() + const bot = { + command: mock((command: string, handler: (ctx: ReturnType['ctx']) => unknown) => { + handlers.set(command, handler) + }), + } + const action = mock(async () => {}) + + registerAuthorizedTelegramCommand(bot, 'new', () => false, action) + + const denied = createCommandContext({ userId: 999 }) + await handlers.get('new')!(denied.ctx) + + expect(action).not.toHaveBeenCalled() + expect(denied.replies[0]).toContain('未授权') + }) + it('syncs official provider command and rejects unauthorized private chats', async () => { const { controller, deps, runtimeModels } = createController() const allowed = createCommandContext({ match: 'claude' }) diff --git a/adapters/telegram/commands.ts b/adapters/telegram/commands.ts index 0c98ce5c..b5f389b9 100644 --- a/adapters/telegram/commands.ts +++ b/adapters/telegram/commands.ts @@ -31,7 +31,7 @@ type TelegramInlineKeyboardMarkup = { inline_keyboard: Array> } -type TelegramCommandContext = { +export type TelegramCommandContext = { chat?: { id: string | number; type?: string } from?: { id: number } match?: string | RegExpMatchArray @@ -80,6 +80,28 @@ export type TelegramCommandRegistrar = { command: (command: string, handler: (ctx: TelegramCommandContext) => unknown) => unknown } +export async function ensureAuthorizedTelegramPrivateChat( + ctx: TelegramCommandContext, + isAllowedUser: (userId: number) => boolean, +): Promise { + if (!ctx.from || ctx.chat?.type !== 'private') return false + if (isAllowedUser(ctx.from.id)) return true + await ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。') + return false +} + +export function registerAuthorizedTelegramCommand( + bot: TelegramCommandRegistrar, + command: string, + isAllowedUser: (userId: number) => boolean, + handler: (ctx: TelegramCommandContext) => unknown | Promise, +): void { + bot.command(command, (ctx) => void (async () => { + if (!await ensureAuthorizedTelegramPrivateChat(ctx, isAllowedUser)) return + await handler(ctx) + })()) +} + export type TelegramRuntimeCommandControllerDeps = { botApi: TelegramSendApi httpClient: AdapterHttpClient @@ -168,13 +190,6 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD } } - const ensureAuthorizedPrivateChat = async (ctx: TelegramCommandContext): Promise => { - if (!ctx.from || ctx.chat?.type !== 'private') return false - if (deps.isAllowedUser(ctx.from.id)) return true - await ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。') - return false - } - const showProviderPicker = async (chatId: string): Promise => { try { const { providers, activeId } = await deps.httpClient.listProviders() @@ -222,7 +237,7 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD } const handleProviderCommand = async (ctx: TelegramCommandContext): Promise => { - if (!await ensureAuthorizedPrivateChat(ctx)) return + if (!await ensureAuthorizedTelegramPrivateChat(ctx, deps.isAllowedUser)) return const chatId = String(ctx.chat!.id) const query = getCommandMatchText(ctx) if (!query) { @@ -290,7 +305,7 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD } const handleModelCommand = async (ctx: TelegramCommandContext): Promise => { - if (!await ensureAuthorizedPrivateChat(ctx)) return + if (!await ensureAuthorizedTelegramPrivateChat(ctx, deps.isAllowedUser)) return const chatId = String(ctx.chat!.id) const modelId = getCommandMatchText(ctx) if (modelId) { @@ -349,7 +364,7 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD } const handleSkillsCommand = async (ctx: TelegramCommandContext): Promise => { - if (!await ensureAuthorizedPrivateChat(ctx)) return + if (!await ensureAuthorizedTelegramPrivateChat(ctx, deps.isAllowedUser)) return await showSkills(String(ctx.chat!.id)) } @@ -392,7 +407,7 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD } const handleResumeCommand = async (ctx: TelegramCommandContext): Promise => { - if (!await ensureAuthorizedPrivateChat(ctx)) return + if (!await ensureAuthorizedTelegramPrivateChat(ctx, deps.isAllowedUser)) return await showResumeProjectPicker(String(ctx.chat!.id)) } diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index cf9d6147..3dffb95f 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -42,7 +42,7 @@ import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js' import type { PendingUpload } from '../common/attachment/attachment-types.js' import * as fs from 'node:fs/promises' import { syncTelegramBotCommands } from './menu.js' -import { createTelegramRuntimeCommandController, registerTelegramExtendedCommands, tryHandleTelegramSelectionCallback } from './commands.js' +import { createTelegramRuntimeCommandController, registerAuthorizedTelegramCommand, registerTelegramExtendedCommands, tryHandleTelegramSelectionCallback } from './commands.js' const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096 const TELEGRAM_STREAMING_TEXT_LIMIT = TELEGRAM_TEXT_LIMIT - 2 // reserve room for cursor @@ -613,17 +613,20 @@ async function startNewSession(chatId: string, query?: string): Promise { } } -bot.command('new', async (ctx) => { +const isAuthorizedTelegramUser = (userId: number) => isAllowedUser('telegram', userId) + +registerAuthorizedTelegramCommand(bot, 'new', isAuthorizedTelegramUser, async (ctx) => { const chatId = String(ctx.chat.id) - await startNewSession(chatId, ctx.match?.trim() || undefined) + const query = typeof ctx.match === 'string' ? ctx.match.trim() : undefined + await startNewSession(chatId, query || undefined) }) -bot.command('projects', async (ctx) => { +registerAuthorizedTelegramCommand(bot, 'projects', isAuthorizedTelegramUser, async (ctx) => { const chatId = String(ctx.chat.id) await showProjectPicker(chatId) }) -bot.command('stop', (ctx) => { +registerAuthorizedTelegramCommand(bot, 'stop', isAuthorizedTelegramUser, (ctx) => { const chatId = String(ctx.chat.id) void (async () => { const stored = await ensureExistingSession(chatId) @@ -636,12 +639,12 @@ bot.command('stop', (ctx) => { })() }) -bot.command('status', async (ctx) => { +registerAuthorizedTelegramCommand(bot, 'status', isAuthorizedTelegramUser, async (ctx) => { const chatId = String(ctx.chat.id) await ctx.reply(await buildStatusText(chatId)) }) -bot.command('clear', (ctx) => { +registerAuthorizedTelegramCommand(bot, 'clear', isAuthorizedTelegramUser, (ctx) => { const chatId = String(ctx.chat.id) void (async () => { const stored = await ensureExistingSession(chatId) diff --git a/desktop/electron/services/navigationGuards.test.ts b/desktop/electron/services/navigationGuards.test.ts index b2663437..1e35fa3c 100644 --- a/desktop/electron/services/navigationGuards.test.ts +++ b/desktop/electron/services/navigationGuards.test.ts @@ -62,10 +62,24 @@ describe('installMainWindowNavigationGuards', () => { expect(openExternal).not.toHaveBeenCalled() }) - it('does not install a will-navigate guard so dev reloads keep working', () => { + it('blocks top-level navigation to remote documents', () => { + const openExternal = vi.fn() + const wc = fakeWebContents() + installMainWindowNavigationGuards(wc.contents, { openExternal }) + + const preventDefault = wc.navigate('https://evil.example/page') + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(openExternal).toHaveBeenCalledWith('https://evil.example/page') + }) + + it('keeps local renderer navigations working', () => { const wc = fakeWebContents() installMainWindowNavigationGuards(wc.contents, { openExternal: vi.fn() }) - expect(wc.hasWillNavigate()).toBe(false) + + expect(wc.hasWillNavigate()).toBe(true) + expect(wc.navigate('http://localhost:5173')).not.toHaveBeenCalled() + expect(wc.navigate('file:///Applications/cc-haha/index.html')).not.toHaveBeenCalled() }) }) diff --git a/desktop/electron/services/navigationGuards.ts b/desktop/electron/services/navigationGuards.ts index 77c8de1e..43f9a2d9 100644 --- a/desktop/electron/services/navigationGuards.ts +++ b/desktop/electron/services/navigationGuards.ts @@ -12,6 +12,20 @@ export type NavigationGuardOptions = { openExternal: (url: string) => void } +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname + .trim() + .replace(/^\[/, '') + .replace(/\]$/, '') + .toLowerCase() + + if (normalized === 'localhost' || normalized === '::1') return true + const parts = normalized.split('.') + return parts.length === 4 && + parts[0] === '127' && + parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255) +} + export function isHttpUrl(url: string): boolean { try { const { protocol } = new URL(url) @@ -21,12 +35,25 @@ export function isHttpUrl(url: string): boolean { } } +export function isAllowedMainWindowNavigationUrl(url: string): boolean { + try { + const parsed = new URL(url) + if (parsed.protocol === 'file:') return true + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return isLoopbackHostname(parsed.hostname) + } + return false + } catch { + return false + } +} + /** * Main app window guard. The renderer is a single-page app loaded from a fixed * entry; it should never spawn an uncontrolled child window. Any window.open / * target=_blank with an http(s) URL is routed to the system browser and the - * Electron popup is denied. We intentionally do NOT install a `will-navigate` - * guard here so that dev HMR reloads and in-app navigations keep working. + * Electron popup is denied. Top-level navigation is restricted to local + * renderer entries so a remote page cannot inherit the privileged preload. */ export function installMainWindowNavigationGuards( webContents: NavigationGuardWebContents, @@ -36,6 +63,11 @@ export function installMainWindowNavigationGuards( if (isHttpUrl(url)) openExternal(url) return { action: 'deny' } }) + webContents.on('will-navigate', (event, url) => { + if (isAllowedMainWindowNavigationUrl(url)) return + event.preventDefault() + if (isHttpUrl(url)) openExternal(url) + }) } /**