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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-08 16:57:32 +08:00
parent f6115e91aa
commit 56b6b45bef
5 changed files with 106 additions and 23 deletions

View File

@ -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<string, (ctx: ReturnType<typeof createCommandContext>['ctx']) => unknown>()
const bot = {
command: mock((command: string, handler: (ctx: ReturnType<typeof createCommandContext>['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' })

View File

@ -31,7 +31,7 @@ type TelegramInlineKeyboardMarkup = {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
}
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<boolean> {
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<unknown>,
): 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<boolean> => {
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<void> => {
try {
const { providers, activeId } = await deps.httpClient.listProviders()
@ -222,7 +237,7 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD
}
const handleProviderCommand = async (ctx: TelegramCommandContext): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
if (!await ensureAuthorizedPrivateChat(ctx)) return
if (!await ensureAuthorizedTelegramPrivateChat(ctx, deps.isAllowedUser)) return
await showResumeProjectPicker(String(ctx.chat!.id))
}

View File

@ -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<void> {
}
}
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)

View File

@ -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()
})
})

View File

@ -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)
})
}
/**