fix(security): add rate limiting, default-closed mode, extract isAllowedUser

- Rate limit: max 5 failed pairing attempts per user per 5 minutes
- Default closed: reject all users when no allowedUsers/pairedUsers configured
- Extract isAllowedUser() to common/pairing.ts to eliminate duplication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-08 20:51:30 +08:00
parent bf5cd6e3b8
commit 158f2de13e
3 changed files with 49 additions and 41 deletions

View File

@ -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<string, { count: number; firstAttempt: number }>()
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
}
}

View File

@ -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<typeof Lark.WSClient> | 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<void> {
// 只处理私聊
if (chatType === 'p2p') {
if (!isAllowedUser(senderOpenId)) {
if (!isAllowedUser('feishu', senderOpenId)) {
// 尝试配对
const pairText = extractText(content, msgType)
if (pairText) {

View File

@ -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<string, boolean>()
// ---------- 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')