diff --git a/adapters/common/pairing.ts b/adapters/common/pairing.ts index c668b5a1..670b41fb 100644 --- a/adapters/common/pairing.ts +++ b/adapters/common/pairing.ts @@ -13,6 +13,32 @@ import * as crypto from 'node:crypto' import type { PairedUser, PairingState } from './config.js' const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L + +// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试 +const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes +const RATE_LIMIT_MAX_ATTEMPTS = 5 +const failedAttempts = new Map() + +function isRateLimited(userId: string | number): boolean { + const key = String(userId) + const record = failedAttempts.get(key) + if (!record) return false + if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) { + failedAttempts.delete(key) + return false + } + return record.count >= RATE_LIMIT_MAX_ATTEMPTS +} + +function recordFailedAttempt(userId: string | number): void { + const key = String(userId) + const record = failedAttempts.get(key) + if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) { + failedAttempts.set(key, { count: 1, firstAttempt: Date.now() }) + } else { + record.count++ + } +} const CODE_LENGTH = 6 const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes @@ -58,8 +84,8 @@ export function isPaired( // allowedUsers 非空时检查 if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true - // allowedUsers 为空且 pairedUsers 也为空 => 旧行为(允许所有人) - if (pairedUsers.length === 0 && allowedUsers.length === 0) return true + // 默认关闭:没有配置任何用户时拒绝访问(需要先配对) + if (pairedUsers.length === 0 && allowedUsers.length === 0) return false return pairedUsers.some((p) => String(p.userId) === String(userId)) } @@ -76,13 +102,19 @@ export function tryPair( const file = readConfigFile() const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null } + // 速率限制检查 + if (isRateLimited(senderInfo.userId)) return false + // 检查配对码是否有效 if (!pairing.code || !pairing.expiresAt) return false if (Date.now() > pairing.expiresAt) return false // 比较(忽略大小写和空格) const input = messageText.trim().toUpperCase() - if (input !== pairing.code.toUpperCase()) return false + if (input !== pairing.code.toUpperCase()) { + recordFailedAttempt(senderInfo.userId) + return false + } // 配对成功:写入 pairedUsers const platformConfig = file[platform] ?? {} @@ -105,3 +137,13 @@ export function tryPair( return true } + +/** 统一的用户授权检查(供各 adapter 调用) */ +export function isAllowedUser(platform: 'telegram' | 'feishu', userId: string | number): boolean { + try { + const cfgFile = readConfigFile() + return isPaired(platform, userId, cfgFile) + } catch { + return false + } +} diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 895cb060..82baedd2 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -16,7 +16,7 @@ import { loadConfig } from '../common/config.js' import { splitMessage, formatToolUse, formatPermissionRequest, truncateInput } from '../common/format.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient } from '../common/http-client.js' -import { isPaired, tryPair } from '../common/pairing.js' +import { isAllowedUser, tryPair } from '../common/pairing.js' // ---------- init ---------- @@ -56,23 +56,6 @@ let wsClient: InstanceType | null = null // ---------- helpers ---------- -function isAllowedUser(openId: string): boolean { - try { - const cfgFile = JSON.parse( - require('node:fs').readFileSync( - require('node:path').join( - process.env.CLAUDE_CONFIG_DIR || require('node:path').join(require('node:os').homedir(), '.claude'), - 'adapters.json' - ), - 'utf-8' - ) - ) - return isPaired('feishu', openId, cfgFile) - } catch { - return false - } -} - function getChatState(chatId: string): ChatState { let state = chatStates.get(chatId) if (!state) { @@ -400,7 +383,7 @@ async function handleMessage(data: any): Promise { // 只处理私聊 if (chatType === 'p2p') { - if (!isAllowedUser(senderOpenId)) { + if (!isAllowedUser('feishu', senderOpenId)) { // 尝试配对 const pairText = extractText(content, msgType) if (pairText) { diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index 3df82673..4d145431 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -14,7 +14,7 @@ import { loadConfig } from '../common/config.js' import { splitMessage, formatToolUse, formatPermissionRequest } from '../common/format.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient } from '../common/http-client.js' -import { isPaired, tryPair } from '../common/pairing.js' +import { isAllowedUser, tryPair } from '../common/pairing.js' const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096 @@ -43,23 +43,6 @@ const pendingProjectSelection = new Map() // ---------- helpers ---------- -function isAllowedUser(userId: number): boolean { - try { - const cfgFile = JSON.parse( - require('node:fs').readFileSync( - require('node:path').join( - process.env.CLAUDE_CONFIG_DIR || require('node:path').join(require('node:os').homedir(), '.claude'), - 'adapters.json' - ), - 'utf-8' - ) - ) - return isPaired('telegram', userId, cfgFile) - } catch { - return false - } -} - function getBuffer(chatId: string): MessageBuffer { let buf = buffers.get(chatId) if (!buf) { @@ -289,7 +272,7 @@ bot.on('message:text', (ctx) => { const text = ctx.message.text // 检查配对状态 - if (!isAllowedUser(userId)) { + if (!isAllowedUser('telegram', userId)) { // 尝试配对 const displayName = [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ') const success = tryPair(text.trim(), { userId, displayName }, 'telegram')