From fca9413f264fc267095b032ef82b51b80e071416 Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Thu, 2 Apr 2026 01:14:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E6=80=A7=E9=97=AE=E9=A2=98=E2=80=94=E2=80=94=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E3=80=81=E6=96=87=E4=BB=B6=E9=94=81=E3=80=81?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A0=A1=E9=AA=8C=E3=80=81=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E5=93=88=E5=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: provider-pool-manager: activeProviderRefreshes 计数器修复,情况1(已有队列)不持有全局槽位,通过 ownsGlobalSlot 闭包变量精确控制递减时机,防止出现负值 - fix: provider-api: 为 handleAddProvider/handleUpdateProvider/handleDeleteProvider/handleDisableEnableProvider 添加模块级 Promise 链文件锁(withFileLock),防止并发读写同一 JSON 文件导致数据丢失 - fix: config-api: handleUpdateConfig 添加输入类型校验——SERVER_PORT 校验整数范围、REQUEST_MAX_RETRIES 校验数值范围、SYSTEM_PROMPT_FILE_PATH 禁止路径遍历(含..)、REQUIRED_API_KEY 限定字符串类型 - fix: config-api/auth: 密码改为 PBKDF2(SHA-512, 100000轮) 哈希存储,格式 pbkdf2:salt:hash,验证使用 timingSafeEqual 防时序攻击,兼容旧明文格式平滑迁移,并增加最小长度 8 位校验 - 所有修改均使用 Node.js 内置 crypto 模块,无新依赖 --- src/providers/provider-pool-manager.js | 24 ++++++++----- src/ui-modules/auth.js | 17 +++++++--- src/ui-modules/config-api.js | 47 +++++++++++++++++++------- src/ui-modules/provider-api.js | 19 +++++++++++ 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 5875f1f..348ddf6 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -283,6 +283,8 @@ export class ProviderPoolManager { } const queue = this.refreshQueues[providerType]; + // 记录此任务是否持有一个全局槽位(情况1追加的任务不持有) + let ownsGlobalSlot = false; const runTask = async () => { try { @@ -291,13 +293,13 @@ export class ProviderPoolManager { this._log('error', `Failed to process refresh for node ${uuid}: ${err.message}`); } finally { this.refreshingUuids.delete(uuid); - + // 再次获取当前队列引用 const currentQueue = this.refreshQueues[providerType]; if (!currentQueue) return; currentQueue.activeCount--; - + // 1. 尝试从当前提供商队列中取下一个任务 if (currentQueue.waitingTasks.length > 0) { const nextTask = currentQueue.waitingTasks.shift(); @@ -306,13 +308,14 @@ export class ProviderPoolManager { Promise.resolve().then(nextTask); } else if (currentQueue.activeCount === 0) { // 2. 如果当前提供商的所有任务都完成了,释放全局槽位 - // 只有在确定队列为空且没有新任务时才清理 - if (currentQueue.waitingTasks.length === 0 && + // 只有持有全局槽位的任务才能递减计数器,避免负值 + if (ownsGlobalSlot && + currentQueue.waitingTasks.length === 0 && this.refreshQueues[providerType] === currentQueue) { this.activeProviderRefreshes--; delete this.refreshQueues[providerType]; // 清理空队列 } - + // 3. 尝试启动下一个等待中的提供商队列 if (this.globalRefreshWaiters.length > 0) { const nextProviderStart = this.globalRefreshWaiters.shift(); @@ -333,15 +336,17 @@ export class ProviderPoolManager { // 检查全局并发限制(按提供商分组) // 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位) - if (this.refreshQueues[providerType].activeCount > 0) { + const isExistingQueue = this.refreshQueues[providerType].activeCount > 0 || this.refreshQueues[providerType].waitingTasks.length > 0; + if (isExistingQueue) { tryStartProviderQueue(); } - // 情况2: 该提供商未运行,需要检查全局槽位 + // 情况2: 该提供商未运行,需要检查全局槽位,此路径持有全局槽位 else if (this.activeProviderRefreshes < this.refreshConcurrency.global) { + ownsGlobalSlot = true; this.activeProviderRefreshes++; tryStartProviderQueue(); } - // 情况3: 全局槽位已满,进入等待队列 + // 情况3: 全局槽位已满,进入等待队列,由等待回调负责标记持槽 else { this.globalRefreshWaiters.push(() => { // 重新获取最新的队列引用 @@ -351,7 +356,8 @@ export class ProviderPoolManager { waitingTasks: [] }; } - // 重要:从等待队列启动时需要增加全局计数 + // 从等待队列启动时持有全局槽位 + ownsGlobalSlot = true; this.activeProviderRefreshes++; tryStartProviderQueue(); }); diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index df3a6a6..219d3b0 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -48,10 +48,19 @@ export async function readPasswordFile() { */ export async function validateCredentials(password) { const storedPassword = await readPasswordFile(); - logger.info('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0); - const isValid = storedPassword && password === storedPassword; - logger.info('[Auth] Password validation result:', isValid); - return isValid; + if (!storedPassword || !password) return false; + + // 新格式:pbkdf2:salt:hash + if (storedPassword.startsWith('pbkdf2:')) { + const parts = storedPassword.split(':'); + if (parts.length !== 3) return false; + const [, salt, storedHash] = parts; + const inputHash = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').toString('hex'); + return crypto.timingSafeEqual(Buffer.from(inputHash, 'hex'), Buffer.from(storedHash, 'hex')); + } + + // 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式) + return password === storedPassword; } /** diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index f6f2b0e..729e685 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import logger from '../utils/logger.js'; import { promises as fs } from 'fs'; import path from 'path'; +import crypto from 'crypto'; import { CONFIG } from '../core/config-manager.js'; import { serviceInstances } from '../providers/adapter.js'; import { initApiService } from '../services/service-manager.js'; @@ -110,16 +111,30 @@ export async function handleUpdateConfig(req, res, currentConfig) { const body = await getRequestBody(req); const newConfig = body; - // Update config values in memory - if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; - if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST; - if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT; + // Update config values in memory(含类型校验) + if (newConfig.REQUIRED_API_KEY !== undefined) { + if (typeof newConfig.REQUIRED_API_KEY === 'string') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; + } + if (newConfig.HOST !== undefined) { + if (typeof newConfig.HOST === 'string' && newConfig.HOST.length > 0) currentConfig.HOST = newConfig.HOST; + } + if (newConfig.SERVER_PORT !== undefined) { + const port = Number(newConfig.SERVER_PORT); + if (Number.isInteger(port) && port > 0 && port < 65536) currentConfig.SERVER_PORT = port; + } if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; - if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) currentConfig.SYSTEM_PROMPT_FILE_PATH = newConfig.SYSTEM_PROMPT_FILE_PATH; + if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) { + const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH); + // 防止路径遍历:只允许相对路径或限定目录 + if (!p.includes('..')) currentConfig.SYSTEM_PROMPT_FILE_PATH = p; + } if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; if (newConfig.PROMPT_LOG_BASE_NAME !== undefined) currentConfig.PROMPT_LOG_BASE_NAME = newConfig.PROMPT_LOG_BASE_NAME; if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; - if (newConfig.REQUEST_MAX_RETRIES !== undefined) currentConfig.REQUEST_MAX_RETRIES = newConfig.REQUEST_MAX_RETRIES; + if (newConfig.REQUEST_MAX_RETRIES !== undefined) { + const v = Number(newConfig.REQUEST_MAX_RETRIES); + if (Number.isInteger(v) && v >= 0 && v <= 100) currentConfig.REQUEST_MAX_RETRIES = v; + } if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES; if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES; @@ -326,17 +341,23 @@ export async function handleUpdateAdminPassword(req, res) { if (!password || password.trim() === '') { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Password cannot be empty' - } - })); + res.end(JSON.stringify({ error: { message: 'Password cannot be empty' } })); return true; } - // 写入密码到 pwd 文件 + if (password.trim().length < 8) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Password must be at least 8 characters' } })); + return true; + } + + // 使用 PBKDF2 哈希存储密码,避免明文写入文件 + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').toString('hex'); + const stored = `pbkdf2:${salt}:${hash}`; + const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); - await fs.writeFile(pwdFilePath, password.trim(), 'utf-8'); + await fs.writeFile(pwdFilePath, stored, 'utf-8'); logger.info('[UI API] Admin password updated successfully'); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index a2a02fc..8c7854d 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -6,6 +6,13 @@ import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFro import { broadcastEvent } from './event-broadcast.js'; import { getRegisteredProviders } from '../providers/adapter.js'; +// 文件级互斥锁:防止并发读写导致数据丢失 +let _fileLockChain = Promise.resolve(); +function withFileLock(fn) { + const next = _fileLockChain.then(() => fn()); + _fileLockChain = next.catch(() => {}); + return next; +} /** * 获取提供商池摘要 */ @@ -93,6 +100,9 @@ export async function handleGetProviderTypeModels(req, res, providerType) { * 添加新的提供商配置 */ export async function handleAddProvider(req, res, currentConfig, providerPoolManager) { + return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)); +} +async function _handleAddProvider(req, res, currentConfig, providerPoolManager) { try { const body = await getRequestBody(req); const { providerType, providerConfig } = body; @@ -180,6 +190,9 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan * 更新特定提供商配置 */ export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); +} +async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { const body = await getRequestBody(req); const { providerConfig } = body; @@ -266,6 +279,9 @@ export async function handleUpdateProvider(req, res, currentConfig, providerPool * 删除特定提供商配置 */ export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)); +} +async function _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {}; @@ -337,6 +353,9 @@ export async function handleDeleteProvider(req, res, currentConfig, providerPool * 禁用/启用特定提供商配置 */ export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { + return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)); +} +async function _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { try { const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; let providerPools = {};