Compare commits

...

6 Commits

Author SHA1 Message Date
github-actions[bot]
c97bfb6478 chore: sync VERSION file with release v1.1.266 [skip ci] 2026-01-24 12:22:09 +00:00
Wesley Liddick
efda870e96 Merge pull request #923 from DaydreamCoding/feature/fix_api_auth
fix(auth): 修复客户端限制绕过漏洞,添加路径白名单检查
2026-01-24 20:21:52 +08:00
Wesley Liddick
1ae310f2a1 Merge pull request #920 from arksou/main [skip ci]
fix: 配额超限优化
2026-01-24 20:21:34 +08:00
QTom
6dc85b39c9 refactor(validators): 消除重复代码,使用映射表和复用函数
代码审查后的重构:
- isPathAllowedForClient 复用 getClientDefinitionById 避免重复查找
- validateRequest 中使用 getClientDefinitionById 替代内联查找
- 使用 VALIDATOR_MAP 映射表替代 switch 语句
- getSupportedClients 改为从映射表动态获取,避免硬编码
- 导入 CLIENT_IDS 枚举,提高类型安全性

这些改动提高了代码的可维护性,添加新客户端时只需修改映射表。
2026-01-24 17:45:13 +08:00
QTom
6c4670213e fix(auth): 修复客户端限制绕过漏洞,添加路径白名单检查
当 API Key 启用客户端限制(如仅允许 Claude Code)时,攻击者可通过
/api/v1/chat/completions 等 OpenAI 兼容端点绕过验证。原因是
ClaudeCodeValidator 对非 messages 路径仅检查 User-Agent。

修复方案:
- 为每个客户端类型定义允许的路径白名单
- 在客户端验证前进行路径检查
- 路径不在白名单中则直接拒绝,无需继续验证

修改文件:
- src/validators/clientDefinitions.js:添加 allowedPathPrefixes 配置
- src/validators/clientValidator.js:添加路径白名单前置检查

Claude Code 限制时的路由保护:
- 允许访问:/api/v1/messages, /claude/v1/messages 等原生端点
- 拒绝访问:/api/v1/chat/completions, /openai/claude/v1/chat/completions 等
- 其他客户端类型(Gemini CLI、Codex CLI、Droid CLI)也同样适用

相关问题:/api/v1/chat/completions 端点在启用 Claude Code 限制后
依然可以使用,深入分析原因并提供修复方案 #security #client-restriction
2026-01-24 17:37:42 +08:00
gaozitian
d16b75293d fix: optimize Claude Console quota exceeded status display
- Keep account status as 'active' when quota exceeded (not 'quota_exceeded')
- Keep isActive as true, only use quotaStoppedAt to mark quota exceeded
- Show green status in UI for quota exceeded accounts (normal state)
- Show '余额不足' as unschedulable reason instead of '已暂停'
- Simplify resetDailyUsage() to only check quotaStoppedAt field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:06:09 +08:00
6 changed files with 130 additions and 58 deletions

View File

@@ -1 +1 @@
1.1.265
1.1.266

View File

@@ -1295,7 +1295,7 @@ class ClaudeConsoleAccountService {
}
// 检查是否已经因额度停用(避免重复操作)
if (!accountData.isActive && accountData.quotaStoppedAt) {
if (accountData.quotaStoppedAt) {
return
}
@@ -1311,9 +1311,9 @@ class ClaudeConsoleAccountService {
return // 已经被其他进程处理
}
// 超过额度,停用账户
// 超过额度,停止调度但保持账户状态正常
// 不修改 isActive 和 status只用独立字段标记配额超限
const updates = {
isActive: false,
quotaStoppedAt: new Date().toISOString(),
errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
schedulable: false, // 停止调度
@@ -1321,13 +1321,6 @@ class ClaudeConsoleAccountService {
quotaAutoStopped: 'true'
}
// 只有当前状态是active时才改为quota_exceeded
// 如果是rate_limited等其他状态保持原状态不变
const currentStatus = await client.hget(accountKey, 'status')
if (currentStatus === 'active') {
updates.status = 'quota_exceeded'
}
await this.updateAccount(accountId, updates)
logger.warn(
@@ -1371,15 +1364,10 @@ class ClaudeConsoleAccountService {
lastResetDate: today
}
// 如果账户是因为超额被停用,恢复账户
// 注意:状态可能是 quota_exceeded 或 rate_limited如果429错误时也超额了
if (
accountData.quotaStoppedAt &&
accountData.isActive === false &&
(accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
) {
updates.isActive = true
updates.status = 'active'
// 如果账户因配额超限被停用,恢复账户
// 新逻辑:不再依赖 isActive === false 和 status 判断
// 只要有 quotaStoppedAt 就说明是因配额超限被停止的
if (accountData.quotaStoppedAt) {
updates.errorMessage = ''
updates.quotaStoppedAt = ''
@@ -1389,16 +1377,7 @@ class ClaudeConsoleAccountService {
updates.quotaAutoStopped = ''
}
// 如果是rate_limited状态也清除限流相关字段
if (accountData.status === 'rate_limited') {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped')
}
logger.info(
`✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
)
logger.info(`✅ Restored account ${accountId} after daily quota reset`)
}
await this.updateAccount(accountId, updates)

View File

@@ -1,6 +1,10 @@
/**
* 客户端定义配置
* 定义所有支持的客户端类型和它们的属性
*
* allowedPathPrefixes: 允许访问的路径前缀白名单
* - 当启用客户端限制时,只有匹配白名单的路径才允许访问
* - 防止通过其他兼容端点(如 /v1/chat/completions绕过客户端限制
*/
const CLIENT_DEFINITIONS = {
@@ -9,7 +13,27 @@ const CLIENT_DEFINITIONS = {
name: 'Claude Code',
displayName: 'Claude Code CLI',
description: 'Claude Code command-line interface',
icon: '🤖'
icon: '🤖',
// Claude Code 仅允许访问 Claude 原生端点,禁止访问 OpenAI 兼容端点
allowedPathPrefixes: [
'/api/v1/messages',
'/api/v1/models',
'/api/v1/me',
'/api/v1/usage',
'/api/v1/key-info',
'/api/v1/organizations',
'/claude/v1/messages',
'/claude/v1/models',
'/antigravity/api/',
'/gemini-cli/api/',
'/api/event_logging',
'/v1/messages',
'/v1/models',
'/v1/me',
'/v1/usage',
'/v1/key-info',
'/v1/organizations'
]
},
GEMINI_CLI: {
@@ -17,7 +41,9 @@ const CLIENT_DEFINITIONS = {
name: 'Gemini CLI',
displayName: 'Gemini Command Line Tool',
description: 'Google Gemini API command-line interface',
icon: '💎'
icon: '💎',
// Gemini CLI 仅允许访问 Gemini 端点
allowedPathPrefixes: ['/gemini/']
},
CODEX_CLI: {
@@ -25,7 +51,9 @@ const CLIENT_DEFINITIONS = {
name: 'Codex CLI',
displayName: 'Codex Command Line Tool',
description: 'Cursor/Codex command-line interface',
icon: '🔷'
icon: '🔷',
// Codex CLI 仅允许访问 OpenAI Responses 和 Azure 端点
allowedPathPrefixes: ['/openai/responses', '/openai/v1/responses', '/azure/']
},
DROID_CLI: {
@@ -33,7 +61,9 @@ const CLIENT_DEFINITIONS = {
name: 'Droid CLI',
displayName: 'Factory Droid CLI',
description: 'Factory Droid platform command-line interface',
icon: '🤖'
icon: '🤖',
// Droid CLI 仅允许访问 Droid 端点
allowedPathPrefixes: ['/droid/']
}
}
@@ -60,10 +90,34 @@ function isValidClientId(clientId) {
return Object.values(CLIENT_IDS).includes(clientId)
}
/**
* 检查路径是否允许指定客户端访问
* @param {string} clientId - 客户端ID
* @param {string} path - 请求路径 (originalUrl 或 path)
* @returns {boolean} 是否允许
*/
function isPathAllowedForClient(clientId, path) {
const definition = getClientDefinitionById(clientId)
if (!definition) {
return false
}
// 如果没有定义 allowedPathPrefixes则不限制路径向后兼容
if (!definition.allowedPathPrefixes || definition.allowedPathPrefixes.length === 0) {
return true
}
const normalizedPath = (path || '').toLowerCase()
return definition.allowedPathPrefixes.some((prefix) =>
normalizedPath.startsWith(prefix.toLowerCase())
)
}
module.exports = {
CLIENT_DEFINITIONS,
CLIENT_IDS,
getAllClientDefinitions,
getClientDefinitionById,
isValidClientId
isValidClientId,
isPathAllowedForClient
}

View File

@@ -4,12 +4,25 @@
*/
const logger = require('../utils/logger')
const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinitions')
const {
CLIENT_IDS,
getAllClientDefinitions,
getClientDefinitionById,
isPathAllowedForClient
} = require('./clientDefinitions')
const ClaudeCodeValidator = require('./clients/claudeCodeValidator')
const GeminiCliValidator = require('./clients/geminiCliValidator')
const CodexCliValidator = require('./clients/codexCliValidator')
const DroidCliValidator = require('./clients/droidCliValidator')
// 客户端ID到验证器的映射表
const VALIDATOR_MAP = {
[CLIENT_IDS.CLAUDE_CODE]: ClaudeCodeValidator,
[CLIENT_IDS.GEMINI_CLI]: GeminiCliValidator,
[CLIENT_IDS.CODEX_CLI]: CodexCliValidator,
[CLIENT_IDS.DROID_CLI]: DroidCliValidator
}
/**
* 客户端验证器类
*/
@@ -20,19 +33,12 @@ class ClientValidator {
* @returns {Object|null} 验证器实例
*/
static getValidator(clientId) {
switch (clientId) {
case 'claude_code':
return ClaudeCodeValidator
case 'gemini_cli':
return GeminiCliValidator
case 'codex_cli':
return CodexCliValidator
case 'droid_cli':
return DroidCliValidator
default:
logger.warn(`Unknown client ID: ${clientId}`)
return null
const validator = VALIDATOR_MAP[clientId]
if (!validator) {
logger.warn(`Unknown client ID: ${clientId}`)
return null
}
return validator
}
/**
@@ -40,7 +46,7 @@ class ClientValidator {
* @returns {Array<string>} 客户端ID列表
*/
static getSupportedClients() {
return ['claude_code', 'gemini_cli', 'codex_cli', 'droid_cli']
return Object.keys(VALIDATOR_MAP)
}
/**
@@ -67,6 +73,7 @@ class ClientValidator {
/**
* 验证请求是否来自允许的客户端列表中的任一客户端
* 包含路径白名单检查,防止通过其他兼容端点绕过客户端限制
* @param {Array<string>} allowedClients - 允许的客户端ID列表
* @param {Object} req - Express请求对象
* @returns {Object} 验证结果对象
@@ -74,10 +81,12 @@ class ClientValidator {
static validateRequest(allowedClients, req) {
const userAgent = req.headers['user-agent'] || ''
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
const requestPath = req.originalUrl || req.path || ''
// 记录验证开始
logger.api(`🔍 Starting client validation for User-Agent: "${userAgent}"`)
logger.api(` Allowed clients: ${allowedClients.join(', ')}`)
logger.api(` Request path: ${requestPath}`)
logger.api(` Request from IP: ${clientIP}`)
// 遍历所有允许的客户端进行验证
@@ -89,6 +98,12 @@ class ClientValidator {
continue
}
// 路径白名单检查:先检查路径是否允许该客户端访问
if (!isPathAllowedForClient(clientId, requestPath)) {
logger.debug(`Path "${requestPath}" not allowed for ${validator.getName()}, skipping`)
continue
}
logger.debug(`Checking against ${validator.getName()}...`)
try {
@@ -96,12 +111,13 @@ class ClientValidator {
// 验证成功
logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`)
logger.api(` Matched User-Agent: "${userAgent}"`)
logger.api(` Allowed path: "${requestPath}"`)
return {
allowed: true,
matchedClient: clientId,
clientName: validator.getName(),
clientInfo: Object.values(CLIENT_DEFINITIONS).find((def) => def.id === clientId)
clientInfo: getClientDefinitionById(clientId)
}
}
} catch (error) {
@@ -111,11 +127,15 @@ class ClientValidator {
}
// 没有匹配的客户端
logger.api(`❌ No matching client found for User-Agent: "${userAgent}"`)
logger.api(
`❌ No matching client found for User-Agent: "${userAgent}" and path: "${requestPath}"`
)
return {
allowed: false,
matchedClient: null,
reason: 'No matching client found'
reason: 'No matching client found or path not allowed',
userAgent,
requestPath
}
}

View File

@@ -3789,7 +3789,7 @@
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true,
"license": "MIT",

View File

@@ -4129,6 +4129,14 @@ const getSchedulableReason = (account) => {
if (account.rateLimitStatus === 'limited') {
return '触发限流429错误'
}
// 检查配额超限状态quotaAutoStopped 或 quotaStoppedAt 任一存在即表示配额超限)
if (
account.quotaAutoStopped === 'true' ||
account.quotaAutoStopped === true ||
account.quotaStoppedAt
) {
return '余额不足'
}
if (account.status === 'blocked' && account.errorMessage) {
return account.errorMessage
}
@@ -4207,6 +4215,15 @@ const getSchedulableReason = (account) => {
return '手动停止调度'
}
// 检查是否是配额超限状态(用于状态显示判断)
const isQuotaExceeded = (account) => {
return (
account.quotaAutoStopped === 'true' ||
account.quotaAutoStopped === true ||
!!account.quotaStoppedAt
)
}
// 获取账户状态文本
const getAccountStatusText = (account) => {
// 检查是否被封锁
@@ -4225,9 +4242,9 @@ const getAccountStatusText = (account) => {
if (account.status === 'temp_error') return '临时异常'
// 检查是否错误
if (account.status === 'error' || !account.isActive) return '错误'
// 检查是否可调度
if (account.schedulable === false) return '已暂停'
// 否则正常
// 配额超限时显示"正常"(不显示"已暂停"
if (account.schedulable === false && !isQuotaExceeded(account)) return '已暂停'
// 否则正常(包括配额超限状态)
return '正常'
}
@@ -4253,7 +4270,8 @@ const getAccountStatusClass = (account) => {
if (account.status === 'error' || !account.isActive) {
return 'bg-red-100 text-red-800'
}
if (account.schedulable === false) {
// 配额超限时显示绿色(正常)
if (account.schedulable === false && !isQuotaExceeded(account)) {
return 'bg-gray-100 text-gray-800'
}
return 'bg-green-100 text-green-800'
@@ -4281,7 +4299,8 @@ const getAccountStatusDotClass = (account) => {
if (account.status === 'error' || !account.isActive) {
return 'bg-red-500'
}
if (account.schedulable === false) {
// 配额超限时显示绿色(正常)
if (account.schedulable === false && !isQuotaExceeded(account)) {
return 'bg-gray-500'
}
return 'bg-green-500'