From 1018750388c6c51a52ccbee7e8bfc7f05539f56a Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Fri, 3 Apr 2026 01:27:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B7=B1=E5=BA=A6review=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=BC=BA=E5=8C=96=E3=80=81i18n=E8=A1=A5=E5=85=A8=E3=80=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全修复: - PBKDF2迭代次数从100k提升至310k(OWASP 2023 SHA-512标准) - 密码最小长度从8位提升至12位 - sanitizeProviderData正则加强:data:协议拒绝而非部分移除, on\w+事件处理器更严格,javascript:加单词边界防止误匹配 - withFileLock错误处理改为重新抛出,不再静默吞错误 - 后端interval上限校验(MAX_INTERVAL_MS)确保配置一致性 功能修复: - 重命名performHealthChecks/performScheduledHealthChecks方法, 明确区分初始化检查和定时检查的职责 - generateUUID回退方案兼容Node.js <14.17.0 - 凭据无expiry字段时强制刷新(安全措施) 代码清理: - 移除未使用的RETRY.DEFAULT_RETRIES常量 - 添加定时健康检查完整英文i18n翻译 --- src/providers/provider-pool-manager.js | 20 +++-- src/services/api-manager.js | 2 +- src/services/api-server.js | 8 +- src/ui-modules/config-api.js | 102 +++++++++++++++---------- src/ui-modules/provider-api.js | 48 ++++++++---- src/utils/constants.js | 12 ++- src/utils/provider-utils.js | 11 ++- static/app/i18n.js | 7 ++ 8 files changed, 136 insertions(+), 74 deletions(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 15bf9f5..f0cb257 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -116,8 +116,11 @@ export class ProviderPoolManager { const credData = JSON.parse(fileContent); const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at; const nearExpiryMs = (currentConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000; - const isNearExpiry = expiryTime && (expiryTime - Date.now()) < nearExpiryMs; - if (isNearExpiry) { + if (!expiryTime) { + // 凭据文件缺少 expiry 字段,无法判断是否快过期,作为安全措施强制刷新 + this._log('warn', `Node ${providerStatus.uuid} (${providerType}) has no expiry field. Forcing refresh as safety measure...`); + this._enqueueRefresh(providerType, providerStatus); + } else if ((expiryTime - Date.now()) < nearExpiryMs) { this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); this._enqueueRefresh(providerType, providerStatus); } @@ -1692,14 +1695,15 @@ export class ProviderPoolManager { } /** - * Performs health checks on selected providers. + * Performs initial (startup) health checks on selected providers. * Respects SCHEDULED_HEALTH_CHECK.providerTypes configuration. - * + * Called once at server startup. + * * 设计决策:如果没有选择任何 provider types,则不进行检查任何 provider。 * 这是有意为之的设计 - 如果用户没有明确选择,则不需要自动健康检查。 * 区别于原来的逻辑(检查所有 provider),现在的行为更符合用户预期。 */ - async performHealthChecks() { + async performInitialHealthChecks() { const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK; const selectedProviderTypes = scheduledConfig?.providerTypes; @@ -1788,7 +1792,7 @@ export class ProviderPoolManager { * This method is designed to be called periodically to proactively check provider health. * It respects provider-level isDisabled flag. */ - async performScheduledHealthChecks() { + async performHealthChecks() { const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK; const checkStartTime = Date.now(); @@ -1920,7 +1924,7 @@ export class ProviderPoolManager { * Performs an actual health check for a specific provider. * * 设计决策:不检查 providerConfig.checkHealth 标志。 - * 健康检查是否执行由上层调用方(performScheduledHealthChecks / performHealthChecks) + * 健康检查是否执行由上层调用方(performHealthChecks / performInitialHealthChecks) * 通过 providerTypes 数组来决定,不在每个 provider 级别控制。 * 这样简化了逻辑,避免 per-provider 的 checkHealth flag 变得无用。 * @@ -1972,7 +1976,7 @@ export class ProviderPoolManager { await serviceAdapter.generateContent(modelName, requestWithSignal); clearTimeout(timeoutId); - // 注意:使用量计数由调用方处理(performScheduledHealthChecks/performHealthChecks) + // 注意:使用量计数由调用方处理(performHealthChecks/performInitialHealthChecks) // 这里只返回成功结果,让调用方统一处理状态更新和计数 return { success: true, modelName, errorMessage: null }; } catch (error) { diff --git a/src/services/api-manager.js b/src/services/api-manager.js index d9cb526..8cacc09 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -68,7 +68,7 @@ export function initializeAPIManagement(services) { logger.info(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`, Object.keys(services)); // 循环遍历所有已初始化的服务适配器,并尝试刷新令牌 // if (getProviderPoolManager()) { - // await getProviderPoolManager().performHealthChecks(); // 定期执行健康检查 + // await getProviderPoolManager().performInitialHealthChecks(); // 定期执行健康检查 // } for (const providerKey in services) { const serviceAdapter = services[providerKey]; diff --git a/src/services/api-server.js b/src/services/api-server.js index 6caf415..4793776 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -359,7 +359,7 @@ async function startServer() { const poolManager = getProviderPoolManager(); if (poolManager) { logger.info('[Initialization] Performing initial health checks for provider pools...'); - poolManager.performHealthChecks(); + poolManager.performInitialHealthChecks(); } // 定时健康检查 @@ -394,7 +394,7 @@ async function startServer() { } isHealthCheckRunning = true; try { - await poolManager.performScheduledHealthChecks(); + await poolManager.performHealthChecks(); } catch (error) { logger.error('[ScheduledHealthCheck] Error:', error); } finally { @@ -426,13 +426,13 @@ async function startServer() { // 启动时运行健康检查 if (scheduledConfig.startupRun !== false) { logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); - setImmediate(async () => { + setTimeout(async () => { try { await poolManager.performScheduledHealthChecks(); } catch (error) { logger.error('[ScheduledHealthCheck] Startup run error:', error); } - }); + }, 100); } // 设置定时任务 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index c62376a..b6a796c 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -129,11 +129,20 @@ export async function handleUpdateConfig(req, res, currentConfig) { // 防止路径遍历:解析后的绝对路径必须在工作目录内 const resolved = path.resolve(process.cwd(), p); const cwd = process.cwd(); - // Windows兼容:统一使用正斜杠进行比较 - const normalizedResolved = resolved.replace(/\\/g, '/'); - const normalizedCwd = cwd.replace(/\\/g, '/'); - if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { + + // 使用 path.relative 和 path.isAbsolute 进行更严格的校验 + const relativePath = path.relative(cwd, resolved); + const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..'; + + // Windows 大小写不敏感兼容:统一转换为小写比较 + const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/'); + const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/'); + const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd; + + if (isInsideCwd && startsWithCwd) { currentConfig.SYSTEM_PROMPT_FILE_PATH = p; + } else { + logger.warn(`[UI API] Rejected SYSTEM_PROMPT_FILE_PATH traversal attempt: ${p}`); } } if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; @@ -174,11 +183,20 @@ export async function handleUpdateConfig(req, res, currentConfig) { // 防止路径遍历:解析后的绝对路径必须在工作目录内 const resolved = path.resolve(process.cwd(), p); const cwd = process.cwd(); - // Windows兼容:统一使用正斜杠进行比较 - const normalizedResolved = resolved.replace(/\\/g, '/'); - const normalizedCwd = cwd.replace(/\\/g, '/'); - if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { + + // 使用 path.relative 和 path.isAbsolute 进行更严格的校验 + const relativePath = path.relative(cwd, resolved); + const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..'; + + // Windows 大小写不敏感兼容:统一转换为小写比较 + const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/'); + const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/'); + const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd; + + if (isInsideCwd && startsWithCwd) { currentConfig.LOG_DIR = p; + } else { + logger.warn(`[UI API] Rejected LOG_DIR traversal attempt: ${p}`); } } if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID; @@ -188,39 +206,45 @@ export async function handleUpdateConfig(req, res, currentConfig) { // Scheduled Health Check settings if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) { - const incoming = newConfig.SCHEDULED_HEALTH_CHECK; - const newInterval = (() => { - const val = Number(incoming?.interval); - return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, val); - })(); - currentConfig.SCHEDULED_HEALTH_CHECK = { - enabled: incoming?.enabled === true, - startupRun: incoming?.startupRun !== false, - interval: newInterval, - providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] - }; + const incoming = newConfig.SCHEDULED_HEALTH_CHECK; - // 检测 enabled 状态变化 - const wasEnabled = currentConfig.SCHEDULED_HEALTH_CHECK?.enabled === true; - const nowEnabled = incoming?.enabled === true; + // 检测 enabled 状态变化(在更新配置之前保存旧状态) + const prevConfig = currentConfig.SCHEDULED_HEALTH_CHECK || {}; + const wasEnabled = prevConfig.enabled === true; + const nowEnabled = incoming?.enabled === true; - if (currentConfig.SCHEDULED_HEALTH_CHECK) { - // 当 enabled 从 true -> false 时,清除 timer - if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) { - globalThis.stopHealthCheckTimer(); - globalThis._activeHealthCheckInterval = undefined; - } - // 当 enabled 从 false -> true 时,启动 timer - else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) { - globalThis._activeHealthCheckInterval = newInterval; - globalThis.reloadHealthCheckTimer(newInterval); - } - // 当 enabled=true 且 interval 变化时,重启 timer - else if (nowEnabled && newInterval !== globalThis._activeHealthCheckInterval) { - globalThis._activeHealthCheckInterval = newInterval; - globalThis.reloadHealthCheckTimer(newInterval); - } - } + const newInterval = (() => { + const val = Number(incoming?.interval); + return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, Math.min(HEALTH_CHECK.MAX_INTERVAL_MS, val)); + })(); + + // 先保存旧的 interval 用于比较 + const oldInterval = globalThis._activeHealthCheckInterval; + + // 更新配置 + currentConfig.SCHEDULED_HEALTH_CHECK = { + enabled: nowEnabled, + startupRun: incoming?.startupRun !== false, + interval: newInterval, + providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] + }; + + // 处理 timer 状态变化 + // 当 enabled 从 true -> false 时,清除 timer + if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) { + globalThis.stopHealthCheckTimer(); + globalThis._activeHealthCheckInterval = undefined; + } + // 当 enabled 从 false -> true 时,启动 timer + else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } + // 当 enabled=true 且 interval 变化时,重启 timer + else if (nowEnabled && newInterval !== oldInterval && globalThis.reloadHealthCheckTimer) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } } // Handle system prompt update diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 534032a..09beaf4 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -7,18 +7,22 @@ import { broadcastEvent } from './event-broadcast.js'; import { getRegisteredProviders } from '../providers/adapter.js'; // 文件级互斥锁:防止并发读写导致数据丢失 -// HTML 脱敏:移除用户输入字段中的 HTML/JS,防止 XSS +// 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等), +// 存储原始文本。HTML 转义统一由前端 escHtml() 负责,避免双编码问题。 function sanitizeProviderData(provider) { if (!provider || typeof provider !== 'object') return provider; const sanitized = { ...provider }; - // 允许在前端显示的纯文本字段做 HTML 转义 if (typeof sanitized.customName === 'string') { - sanitized.customName = sanitized.customName - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + let name = sanitized.customName; + // 拒绝包含 data: 协议(可能包含内嵌恶意内容) + if (/data\s*:/i.test(name)) return sanitized; + // 移除 (支持跨行匹配) + name = name.replace(/)<[^<]*)*<\/script>/gi, ''); + // 移除 HTML 事件处理器属性(onclick/onerror 等) + name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); + // 移除 javascript: 协议(更严格:要求独立单词边界,防止误匹配 "not javascript code") + name = name.replace(/\bjavascript\s*:/gi, ''); + sanitized.customName = name.trim(); } return sanitized; } @@ -39,9 +43,9 @@ function withFileLock(fn) { const next = _fileLockChain .then(() => fn()) .catch(err => { - // 记录错误但继续链式执行,防止死锁 + // 记录错误并抛出,中断操作 logger.error('[FileLock] Operation failed:', err?.message || err); - return null; + throw err; }); _fileLockChain = next.then(() => {}).catch(() => {}); return next; @@ -133,7 +137,11 @@ export async function handleGetProviderTypeModels(req, res, providerType) { * 添加新的提供商配置 */ export async function handleAddProvider(req, res, currentConfig, providerPoolManager) { - return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)); + return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleAddProvider(req, res, currentConfig, providerPoolManager) { try { @@ -223,7 +231,11 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) * 更新特定提供商配置 */ export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { - return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); + return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { @@ -312,7 +324,11 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage * 删除特定提供商配置 */ export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { - return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); + return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { @@ -386,7 +402,11 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage * 禁用/启用特定提供商配置 */ export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { - return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)); + return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)).catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } })); + return true; + }); } async function _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { try { diff --git a/src/utils/constants.js b/src/utils/constants.js index 20c0071..2ff652c 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -15,10 +15,10 @@ export const HEALTH_CHECK = { // 密码安全相关常量 export const PASSWORD = { - // 最小密码长度 - MIN_LENGTH: 8, - // PBKDF2迭代次数 - PBKDF2_ITERATIONS: 100000, + // 最小密码长度(最少12位,与现代安全实践一致) + MIN_LENGTH: 12, + // PBKDF2迭代次数(OWASP 2023建议 SHA-512 ≥310,000次) + PBKDF2_ITERATIONS: 310000, // PBKDF2密钥长度(字节) PBKDF2_KEYLEN: 64, // PBKDF2哈希算法 @@ -38,7 +38,5 @@ export const NETWORK = { // 请求重试相关常量 export const RETRY = { // 最大重试次数 - MAX_RETRIES: 100, - // 默认重试次数 - DEFAULT_RETRIES: 3 + MAX_RETRIES: 100 }; diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index d7e9134..16c1eb7 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -94,10 +94,19 @@ export const PROVIDER_MAPPINGS = [ /** * 生成 UUID + * 兼容旧版 Node.js(<14.17.0):如果 crypto.randomUUID 不存在则使用 Math.random 回退方案 * @returns {string} UUID 字符串 */ export function generateUUID() { - return crypto.randomUUID(); + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // 回退方案:使用 Math.random 生成标准 UUID v4 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); } /** diff --git a/static/app/i18n.js b/static/app/i18n.js index 7512919..13a06ee 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -1195,6 +1195,13 @@ const translations = { 'config.proxy.tlsSidecarProxyUrl': 'Sidecar Upstream Proxy', 'config.proxy.tlsSidecarEnabledProviders': 'Providers Using TLS Sidecar', 'config.proxy.tlsSidecarNote': 'When enabled, requests for selected providers are routed through Go uTLS sidecar for perfect Chrome TLS/H2 fingerprint to bypass Cloudflare (requires restart)', + 'config.healthCheck.title': 'Scheduled Health Check', + 'config.healthCheck.enabled': 'Enable Scheduled Check', + 'config.healthCheck.startupRun': 'Run on Startup', + 'config.healthCheck.interval': 'Check Interval', + 'config.healthCheck.intervalNote': 'In milliseconds. Minimum 60000ms (1 min), maximum 3600000ms (1 hour). Enter manually or use quick select buttons', + 'config.healthCheck.providerTypes': 'Providers to Check', + 'config.healthCheck.providerTypesNote': 'Select provider types for scheduled health checks. Leave empty to skip all checks', 'config.log.title': 'Log Settings', 'config.log.enabled': 'Enable Logging', 'config.log.outputMode': 'Log Output Mode',