fix: 修复架构性问题——并发计数、文件锁、输入校验、密码哈希

- 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🧂hash,验证使用 timingSafeEqual 防时序攻击,兼容旧明文格式平滑迁移,并增加最小长度 8 位校验
- 所有修改均使用 Node.js 内置 crypto 模块,无新依赖
This commit is contained in:
Wenaixi 2026-04-02 01:14:53 +08:00
parent a97b05dd2d
commit fca9413f26
4 changed files with 81 additions and 26 deletions

View file

@ -283,6 +283,8 @@ export class ProviderPoolManager {
} }
const queue = this.refreshQueues[providerType]; const queue = this.refreshQueues[providerType];
// 记录此任务是否持有一个全局槽位情况1追加的任务不持有
let ownsGlobalSlot = false;
const runTask = async () => { const runTask = async () => {
try { try {
@ -306,8 +308,9 @@ export class ProviderPoolManager {
Promise.resolve().then(nextTask); Promise.resolve().then(nextTask);
} else if (currentQueue.activeCount === 0) { } else if (currentQueue.activeCount === 0) {
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位 // 2. 如果当前提供商的所有任务都完成了,释放全局槽位
// 只有在确定队列为空且没有新任务时才清理 // 只有持有全局槽位的任务才能递减计数器,避免负值
if (currentQueue.waitingTasks.length === 0 && if (ownsGlobalSlot &&
currentQueue.waitingTasks.length === 0 &&
this.refreshQueues[providerType] === currentQueue) { this.refreshQueues[providerType] === currentQueue) {
this.activeProviderRefreshes--; this.activeProviderRefreshes--;
delete this.refreshQueues[providerType]; // 清理空队列 delete this.refreshQueues[providerType]; // 清理空队列
@ -333,15 +336,17 @@ export class ProviderPoolManager {
// 检查全局并发限制(按提供商分组) // 检查全局并发限制(按提供商分组)
// 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位) // 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位)
if (this.refreshQueues[providerType].activeCount > 0) { const isExistingQueue = this.refreshQueues[providerType].activeCount > 0 || this.refreshQueues[providerType].waitingTasks.length > 0;
if (isExistingQueue) {
tryStartProviderQueue(); tryStartProviderQueue();
} }
// 情况2: 该提供商未运行,需要检查全局槽位 // 情况2: 该提供商未运行,需要检查全局槽位,此路径持有全局槽位
else if (this.activeProviderRefreshes < this.refreshConcurrency.global) { else if (this.activeProviderRefreshes < this.refreshConcurrency.global) {
ownsGlobalSlot = true;
this.activeProviderRefreshes++; this.activeProviderRefreshes++;
tryStartProviderQueue(); tryStartProviderQueue();
} }
// 情况3: 全局槽位已满,进入等待队列 // 情况3: 全局槽位已满,进入等待队列,由等待回调负责标记持槽
else { else {
this.globalRefreshWaiters.push(() => { this.globalRefreshWaiters.push(() => {
// 重新获取最新的队列引用 // 重新获取最新的队列引用
@ -351,7 +356,8 @@ export class ProviderPoolManager {
waitingTasks: [] waitingTasks: []
}; };
} }
// 重要:从等待队列启动时需要增加全局计数 // 从等待队列启动时持有全局槽位
ownsGlobalSlot = true;
this.activeProviderRefreshes++; this.activeProviderRefreshes++;
tryStartProviderQueue(); tryStartProviderQueue();
}); });

View file

@ -48,10 +48,19 @@ export async function readPasswordFile() {
*/ */
export async function validateCredentials(password) { export async function validateCredentials(password) {
const storedPassword = await readPasswordFile(); const storedPassword = await readPasswordFile();
logger.info('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0); if (!storedPassword || !password) return false;
const isValid = storedPassword && password === storedPassword;
logger.info('[Auth] Password validation result:', isValid); // 新格式pbkdf2:salt:hash
return isValid; 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;
} }
/** /**

View file

@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import crypto from 'crypto';
import { CONFIG } from '../core/config-manager.js'; import { CONFIG } from '../core/config-manager.js';
import { serviceInstances } from '../providers/adapter.js'; import { serviceInstances } from '../providers/adapter.js';
import { initApiService } from '../services/service-manager.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 body = await getRequestBody(req);
const newConfig = body; const newConfig = body;
// Update config values in memory // Update config values in memory含类型校验
if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; if (newConfig.REQUIRED_API_KEY !== undefined) {
if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST; if (typeof newConfig.REQUIRED_API_KEY === 'string') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY;
if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT; }
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.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.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_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.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.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.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; 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() === '') { if (!password || password.trim() === '') {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({ error: { message: 'Password cannot be empty' } }));
error: {
message: 'Password cannot be empty'
}
}));
return true; 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'); 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'); logger.info('[UI API] Admin password updated successfully');

View file

@ -6,6 +6,13 @@ import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFro
import { broadcastEvent } from './event-broadcast.js'; import { broadcastEvent } from './event-broadcast.js';
import { getRegisteredProviders } from '../providers/adapter.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) { export async function handleAddProvider(req, res, currentConfig, providerPoolManager) {
return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager));
}
async function _handleAddProvider(req, res, currentConfig, providerPoolManager) {
try { try {
const body = await getRequestBody(req); const body = await getRequestBody(req);
const { providerType, providerConfig } = body; 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) { 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 { try {
const body = await getRequestBody(req); const body = await getRequestBody(req);
const { providerConfig } = body; 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) { 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 { try {
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
let providerPools = {}; 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) { 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 { try {
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
let providerPools = {}; let providerPools = {};