From 6c4670213ecce53834c802e76f1aa2967727c6f2 Mon Sep 17 00:00:00 2001 From: QTom Date: Sat, 24 Jan 2026 17:35:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E9=99=90=E5=88=B6=E7=BB=95=E8=BF=87=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E=EF=BC=8C=E6=B7=BB=E5=8A=A0=E8=B7=AF=E5=BE=84=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 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 --- src/validators/clientDefinitions.js | 64 ++++++++++++++++++++++++++--- src/validators/clientValidator.js | 24 +++++++++-- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/validators/clientDefinitions.js b/src/validators/clientDefinitions.js index 89c3e528..0ca9619d 100644 --- a/src/validators/clientDefinitions.js +++ b/src/validators/clientDefinitions.js @@ -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 = Object.values(CLIENT_DEFINITIONS).find((d) => d.id === 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 } diff --git a/src/validators/clientValidator.js b/src/validators/clientValidator.js index 13cb38eb..1f655c06 100644 --- a/src/validators/clientValidator.js +++ b/src/validators/clientValidator.js @@ -4,7 +4,11 @@ */ const logger = require('../utils/logger') -const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinitions') +const { + CLIENT_DEFINITIONS, + getAllClientDefinitions, + isPathAllowedForClient +} = require('./clientDefinitions') const ClaudeCodeValidator = require('./clients/claudeCodeValidator') const GeminiCliValidator = require('./clients/geminiCliValidator') const CodexCliValidator = require('./clients/codexCliValidator') @@ -67,6 +71,7 @@ class ClientValidator { /** * 验证请求是否来自允许的客户端列表中的任一客户端 + * 包含路径白名单检查,防止通过其他兼容端点绕过客户端限制 * @param {Array} allowedClients - 允许的客户端ID列表 * @param {Object} req - Express请求对象 * @returns {Object} 验证结果对象 @@ -74,10 +79,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 +96,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,6 +109,7 @@ class ClientValidator { // 验证成功 logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`) logger.api(` Matched User-Agent: "${userAgent}"`) + logger.api(` Allowed path: "${requestPath}"`) return { allowed: true, @@ -111,11 +125,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 } }