mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
f6115e91aa
commit
56b6b45bef
@ -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' })
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user