fix: 深度review后续修复——安全强化、i18n补全、代码清理

安全修复:
- 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翻译
This commit is contained in:
Wenaixi 2026-04-03 01:27:34 +08:00
parent 740f930f34
commit 1018750388
8 changed files with 136 additions and 74 deletions

View file

@ -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) {

View file

@ -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];

View file

@ -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);
}
// 设置定时任务

View file

@ -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

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
let name = sanitized.customName;
// 拒绝包含 data: 协议(可能包含内嵌恶意内容)
if (/data\s*:/i.test(name)) return sanitized;
// 移除 <script>...</script>(支持跨行匹配)
name = name.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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 {

View file

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

View file

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

View file

@ -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',