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];
// 记录此任务是否持有一个全局槽位情况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();
});

View file

@ -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;
}
/**

View file

@ -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');

View file

@ -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 = {};