Merge pull request #430 from Wenaixi/main

feat: 定时健康检查系统 + 全面安全强化与稳定性优化
This commit is contained in:
何夕2077 2026-04-03 17:30:23 +08:00 committed by GitHub
commit 734c63bf7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1275 additions and 106 deletions

1
.gitignore vendored
View file

@ -15,4 +15,5 @@ api-potluck-keys.json
api-potluck-data.json
# Codex credentials
configs/codex/
AGENTS.md

View file

@ -7,7 +7,7 @@ services:
restart: unless-stopped
ports:
- "3000:3000"
- "8085-8087:8085-8087"
- "8085-8087:8085-8087"
- "1455:1455"
- "19876-19880:19876-19880"
volumes:

View file

@ -112,7 +112,15 @@ export class ProviderPoolManager {
if (configPath && fs.existsSync(configPath)) {
try {
if (true) {
const fileContent = fs.readFileSync(configPath, 'utf-8');
const credData = JSON.parse(fileContent);
const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at;
const nearExpiryMs = (this.globalConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000;
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);
}
@ -278,6 +286,8 @@ export class ProviderPoolManager {
}
const queue = this.refreshQueues[providerType];
// 记录此任务是否持有一个全局槽位情况1追加的任务不持有
let ownsGlobalSlot = false;
const runTask = async () => {
try {
@ -286,13 +296,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();
@ -300,14 +310,17 @@ export class ProviderPoolManager {
// 使用 Promise.resolve().then 避免过深的递归
Promise.resolve().then(nextTask);
} else if (currentQueue.activeCount === 0) {
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
// 只有在确定队列为空且没有新任务时才清理
// 清理空队列:无论是否持有全局槽位,都应删除已无任务的队列对象
if (currentQueue.waitingTasks.length === 0 &&
this.refreshQueues[providerType] === currentQueue) {
this.activeProviderRefreshes--;
delete this.refreshQueues[providerType]; // 清理空队列
delete this.refreshQueues[providerType];
}
// 只有持有全局槽位的任务才能递减计数器
if (ownsGlobalSlot) {
this.activeProviderRefreshes--;
}
// 3. 尝试启动下一个等待中的提供商队列
if (this.globalRefreshWaiters.length > 0) {
const nextProviderStart = this.globalRefreshWaiters.shift();
@ -328,15 +341,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(() => {
// 重新获取最新的队列引用
@ -346,7 +361,8 @@ export class ProviderPoolManager {
waitingTasks: []
};
}
// 重要:从等待队列启动时需要增加全局计数
// 从等待队列启动时持有全局槽位
ownsGlobalSlot = true;
this.activeProviderRefreshes++;
tryStartProviderQueue();
});
@ -389,7 +405,16 @@ export class ProviderPoolManager {
// 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑)
if (typeof serviceAdapter.refreshToken === 'function') {
const startTime = Date.now();
force ? await serviceAdapter.forceRefreshToken() : await serviceAdapter.refreshToken()
if (force) {
if (typeof serviceAdapter.forceRefreshToken === 'function') {
await serviceAdapter.forceRefreshToken();
} else {
this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`);
await serviceAdapter.refreshToken();
}
} else {
await serviceAdapter.refreshToken();
}
const duration = Date.now() - startTime;
this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`);
@ -452,7 +477,7 @@ export class ProviderPoolManager {
const lastSelectionSeq = config._lastSelectionSeq || 0;
if (minSeqInPool === -1) {
const pool = this.providerStatus[providerStatus.type] || [];
minSeqInPool = Math.min(...pool.map(p => p.config._lastSelectionSeq || 0));
minSeqInPool = pool.reduce((min, p) => Math.min(min, p.config._lastSelectionSeq || 0), Infinity);
}
const relativeSeq = Math.max(0, lastSelectionSeq - minSeqInPool);
const cappedRelativeSeq = Math.min(relativeSeq, 100);
@ -1670,17 +1695,36 @@ export class ProviderPoolManager {
}
/**
* Performs health checks on all providers in the pool.
* This method would typically be called periodically (e.g., via cron job).
* 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(isInit = false) {
this._log('info', 'Performing health checks on all providers...');
async performInitialHealthChecks() {
const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK;
const selectedProviderTypes = scheduledConfig?.providerTypes;
// 如果没有选择任何 provider types不进行检查
// 设计决策:如果用户没有选择任何 provider明确不执行健康检查是合理的
if (!Array.isArray(selectedProviderTypes) || selectedProviderTypes.length === 0) {
return;
}
this._log('info', 'Performing health checks on selected providers...');
const now = new Date();
// 首先检查并恢复已到恢复时间的提供商
this._checkAndRecoverScheduledProviders();
for (const providerType in this.providerStatus) {
// Only check selected provider types
if (!selectedProviderTypes.includes(providerType)) {
continue;
}
for (const providerStatus of this.providerStatus[providerType]) {
const providerConfig = providerStatus.config;
@ -1743,6 +1787,91 @@ export class ProviderPoolManager {
}
}
/**
* Performs scheduled health checks on all providers.
* This method is designed to be called periodically to proactively check provider health.
* It respects provider-level isDisabled flag.
*/
async performHealthChecks() {
const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK;
const checkStartTime = Date.now();
// Check if scheduled health checks are disabled
if (!scheduledConfig?.enabled) {
this._log('debug', '[ScheduledHealthCheck] Scheduled health checks are disabled via configuration');
return;
}
// Get selected provider types
let selectedProviderTypes = scheduledConfig?.providerTypes;
// Validate providerTypes is an array
if (!Array.isArray(selectedProviderTypes) || selectedProviderTypes.length === 0) {
this._log('info', '[ScheduledHealthCheck] No provider types selected, skipping health check');
return;
}
// Count providers to be checked
let totalProviders = 0;
let providersToCheck = [];
for (const providerType in this.providerStatus) {
// Only check selected provider types
if (!selectedProviderTypes.includes(providerType)) {
this._log('debug', `[ScheduledHealthCheck] Skipping provider type ${providerType}: not in selected types`);
continue;
}
for (const provider of this.providerStatus[providerType]) {
// Skip manually disabled providers
if (provider.config.isDisabled === true) {
this._log('debug', `[ScheduledHealthCheck] Skipping ${provider.config.uuid} (${providerType}): manually disabled`);
continue;
}
totalProviders++;
providersToCheck.push({ providerType, provider, uuid: provider.config.uuid, customName: provider.config.customName });
}
}
this._log('info', `[ScheduledHealthCheck] Starting scheduled health checks: ${totalProviders} provider(s) to check (interval: ${scheduledConfig.interval}ms, types: ${selectedProviderTypes.join(', ')})`);
let successCount = 0;
let failCount = 0;
for (const { providerType, provider, uuid, customName } of providersToCheck) {
const providerCheckStart = Date.now();
const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown';
const displayName = customName || uuid.substring(0, 8);
try {
// Perform health check (health check is based on providerTypes configuration, not per-provider checkHealth flag)
const result = await this._checkProviderHealth(providerType, provider.config);
const checkDuration = Date.now() - providerCheckStart;
if (!result.success) {
// Provider is unhealthy
failCount++;
this._log('warn', `[ScheduledHealthCheck] ${displayName} (${providerType}) FAILED: ${result.errorMessage || 'Provider is not responding correctly.'} (${checkDuration}ms)`);
this.markProviderUnhealthyImmediately(providerType, provider.config, result.errorMessage);
} else {
// Provider is healthy
successCount++;
this._log('info', `[ScheduledHealthCheck] ${displayName} (${providerType}) PASSED: model=${result.modelName || checkModelName} (${checkDuration}ms)`);
this.markProviderHealthy(providerType, provider.config, false, result.modelName);
}
} catch (error) {
const checkDuration = Date.now() - providerCheckStart;
failCount++;
this._log('error', `[ScheduledHealthCheck] ${displayName} (${providerType}) EXCEPTION: ${error.message} (${checkDuration}ms)`);
this.markProviderUnhealthyImmediately(providerType, provider.config, error.message);
}
}
const totalDuration = Date.now() - checkStartTime;
this._log('info', `[ScheduledHealthCheck] Completed: ${successCount} passed, ${failCount} failed, ${totalDuration}ms total`);
}
/**
* 构建健康检查请求返回多种格式用于重试
* @private
@ -1793,17 +1922,17 @@ export class ProviderPoolManager {
/**
* Performs an actual health check for a specific provider.
*
* 设计决策不检查 providerConfig.checkHealth 标志
* 健康检查是否执行由上层调用方performHealthChecks / performInitialHealthChecks
* 通过 providerTypes 数组来决定不在每个 provider 级别控制
* 这样简化了逻辑避免 per-provider checkHealth flag 变得无用
*
* @param {string} providerType - The type of the provider.
* @param {object} providerConfig - The configuration of the provider to check.
* @param {boolean} forceCheck - If true, ignore checkHealth config and force the check.
* @returns {Promise<{success: boolean, modelName: string, errorMessage: string}|null>} - Health check result object or null if check not implemented.
* @returns {Promise<{success: boolean, modelName: string, errorMessage: string}>} - Health check result object.
*/
async _checkProviderHealth(providerType, providerConfig, forceCheck = false) {
// 如果未启用健康检查且不是强制检查,返回 null提前返回避免不必要的计算
if (!providerConfig.checkHealth && !forceCheck) {
return null;
}
async _checkProviderHealth(providerType, providerConfig) {
// 确定健康检查使用的模型名称
const modelName = providerConfig.checkModelName ||
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType];
@ -1838,8 +1967,6 @@ export class ProviderPoolManager {
const timeoutId = setTimeout(() => abortController.abort(), healthCheckTimeout);
try {
this._log('debug', `Health check attempt ${i + 1}/${healthCheckRequests.length} for ${modelName}: ${JSON.stringify(healthCheckRequest)}`);
// 尝试将 signal 注入请求体,供支持的适配器使用
const requestWithSignal = {
...healthCheckRequest,
@ -1849,16 +1976,17 @@ export class ProviderPoolManager {
await serviceAdapter.generateContent(modelName, requestWithSignal);
clearTimeout(timeoutId);
// 注意使用量计数由调用方处理performHealthChecks/performInitialHealthChecks
// 这里只返回成功结果,让调用方统一处理状态更新和计数
return { success: true, modelName, errorMessage: null };
} catch (error) {
clearTimeout(timeoutId);
lastError = error;
this._log('debug', `Health check attempt ${i + 1} failed for ${providerType}: ${error.message}`);
}
}
// 所有尝试都失败
this._log('error', `Health check failed for ${providerType} after ${healthCheckRequests.length} attempts: ${lastError?.message}`);
this._log('warn', `[HealthCheck] ${providerType} failed after ${healthCheckRequests.length} attempts: ${lastError?.message}`);
return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' };
}

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

@ -7,6 +7,7 @@ import { initializeAPIManagement } from './api-manager.js';
import { createRequestHandler } from '../handlers/request-handler.js';
import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js';
import { getTLSSidecar } from '../utils/tls-sidecar.js';
import { HEALTH_CHECK } from '../utils/constants.js';
/**
* @license
@ -313,7 +314,7 @@ async function startServer() {
logger.info(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`);
logger.info(` Host: ${CONFIG.HOST}`);
logger.info(` Port: ${CONFIG.SERVER_PORT}`);
logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`);
logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY ? CONFIG.REQUIRED_API_KEY.slice(0, 4) + '****' : '(none)'}`);
logger.info(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${CONFIG.PROMPT_LOG_FILENAME ? ` (to ${CONFIG.PROMPT_LOG_FILENAME})` : ''}`);
logger.info(`------------------------------------------`);
logger.info(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`);
@ -358,7 +359,86 @@ async function startServer() {
const poolManager = getProviderPoolManager();
if (poolManager) {
logger.info('[Initialization] Performing initial health checks for provider pools...');
poolManager.performHealthChecks(true);
poolManager.performInitialHealthChecks();
}
// 定时健康检查
// 注意:无论初始 enabled 状态如何,都注册 reloadHealthCheckTimer
// 使得热更新时(从 disabled→enabledconfig-api 能调用它启动 timer。
const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK;
{
const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000;
let isHealthCheckRunning = false;
let healthCheckTimerId = null;
// 定时健康检查函数(始终注册,无论初始 enabled 状态)
const runHealthCheckTimer = (interval) => {
// 清除旧的 timer
if (healthCheckTimerId) {
clearInterval(healthCheckTimerId);
healthCheckTimerId = null;
}
// 重置运行状态,允许新的 timer 立即触发
// 否则如果 reload 时正在运行,新 timer 的第一次触发会被跳过
isHealthCheckRunning = false;
// 设置定时任务
// 设计决策:只验证最小值,不设最大值。
// 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。
const safeInterval = (typeof interval === 'number' && interval >= HEALTH_CHECK.MIN_INTERVAL_MS) ? interval : DEFAULT_INTERVAL;
healthCheckTimerId = setInterval(async () => {
if (isHealthCheckRunning) {
logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress');
return;
}
isHealthCheckRunning = true;
try {
await poolManager.performHealthChecks();
} catch (error) {
logger.error('[ScheduledHealthCheck] Error:', error);
} finally {
isHealthCheckRunning = false;
}
}, safeInterval);
logger.info(`[ScheduledHealthCheck] Scheduled every ${safeInterval}ms`);
return safeInterval;
};
// 注册重载/停止函数到 globalThis供 config-api 热更新使用)
// 必须在 enabled 检查外注册,保证热更新时可访问
globalThis.reloadHealthCheckTimer = runHealthCheckTimer;
globalThis.stopHealthCheckTimer = () => {
if (healthCheckTimerId) {
clearInterval(healthCheckTimerId);
healthCheckTimerId = null;
logger.info('[ScheduledHealthCheck] Timer stopped');
}
};
if (scheduledConfig?.enabled) {
let interval = scheduledConfig.interval;
if (typeof interval !== 'number' || interval < HEALTH_CHECK.MIN_INTERVAL_MS) {
logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`);
interval = DEFAULT_INTERVAL;
}
// 启动时运行健康检查
if (scheduledConfig.startupRun !== false) {
logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...');
setTimeout(async () => {
try {
await poolManager.performHealthChecks();
} catch (error) {
logger.error('[ScheduledHealthCheck] Startup run error:', error);
}
}, 100);
}
// 设置定时任务
const activeInterval = runHealthCheckTimer(interval);
globalThis._activeHealthCheckInterval = activeInterval;
}
}
// 如果是子进程,通知主进程已就绪

View file

@ -5,6 +5,7 @@ import path from 'path';
import crypto from 'crypto';
import { CONFIG } from '../core/config-manager.js';
import { getClientIp } from '../utils/common.js';
import { PASSWORD } from '../utils/constants.js';
// Token存储到本地文件中
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
@ -48,10 +49,27 @@ 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 = await new Promise((resolve, reject) =>
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
err ? reject(err) : resolve(key.toString('hex'))
)
);
return crypto.timingSafeEqual(Buffer.from(inputHash, 'hex'), Buffer.from(storedHash, 'hex'));
}
// 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式)
// 使用 timingSafeEqual 防止时序攻击
const a = Buffer.from(password.trim());
const b = Buffer.from(storedPassword);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/**

View file

@ -2,11 +2,13 @@ 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';
import { getRequestBody } from '../utils/common.js';
import { broadcastEvent } from '../ui-modules/event-broadcast.js';
import { HEALTH_CHECK, PASSWORD, NETWORK, RETRY } from '../utils/constants.js';
/**
* 重载配置文件
@ -57,11 +59,48 @@ export async function handleGetConfig(req, res, currentConfig) {
}
}
// 白名单过滤:只返回前端需要的字段,避免泄露凭据路径、内部状态等敏感信息
const safeConfig = {
HOST: currentConfig.HOST,
SERVER_PORT: currentConfig.SERVER_PORT,
MODEL_PROVIDER: currentConfig.MODEL_PROVIDER,
SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH,
SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE,
PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME,
PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE,
REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES,
REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY,
CREDENTIAL_SWITCH_MAX_RETRIES: currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES,
CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES,
CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN,
LOGIN_EXPIRY: currentConfig.LOGIN_EXPIRY,
PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH,
MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT,
WARMUP_TARGET: currentConfig.WARMUP_TARGET,
REFRESH_CONCURRENCY_PER_PROVIDER: currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER,
providerFallbackChain: currentConfig.providerFallbackChain,
modelFallbackMapping: currentConfig.modelFallbackMapping,
PROXY_URL: currentConfig.PROXY_URL,
PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS,
TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED,
TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS,
TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT,
TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL,
LOG_ENABLED: currentConfig.LOG_ENABLED,
LOG_OUTPUT_MODE: currentConfig.LOG_OUTPUT_MODE,
LOG_LEVEL: currentConfig.LOG_LEVEL,
LOG_DIR: currentConfig.LOG_DIR,
LOG_INCLUDE_REQUEST_ID: currentConfig.LOG_INCLUDE_REQUEST_ID,
LOG_INCLUDE_TIMESTAMP: currentConfig.LOG_INCLUDE_TIMESTAMP,
LOG_MAX_FILE_SIZE: currentConfig.LOG_MAX_FILE_SIZE,
LOG_MAX_FILES: currentConfig.LOG_MAX_FILES,
SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK,
// 脱敏:只返回是否设置了 API Key不返回原文
REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY ? '******' : '',
systemPrompt,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...currentConfig,
systemPrompt
}));
res.end(JSON.stringify(safeConfig));
return true;
}
@ -73,16 +112,47 @@ 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 >= NETWORK.MIN_PORT && port <= NETWORK.MAX_PORT) 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);
// 防止路径遍历:解析后的绝对路径必须在工作目录内
const resolved = path.resolve(process.cwd(), p);
const cwd = process.cwd();
// 使用 path.relative 和 path.isAbsolute 进行更严格的校验
const relativePath = path.relative(cwd, resolved);
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
// Windows 大小写不敏感兼容:仅在 Windows 平台统一转换为小写比较
const isWindows = process.platform === 'win32';
const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/');
const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).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;
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 <= RETRY.MAX_RETRIES) 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;
@ -109,12 +179,76 @@ export async function handleUpdateConfig(req, res, currentConfig) {
if (newConfig.LOG_ENABLED !== undefined) currentConfig.LOG_ENABLED = newConfig.LOG_ENABLED;
if (newConfig.LOG_OUTPUT_MODE !== undefined) currentConfig.LOG_OUTPUT_MODE = newConfig.LOG_OUTPUT_MODE;
if (newConfig.LOG_LEVEL !== undefined) currentConfig.LOG_LEVEL = newConfig.LOG_LEVEL;
if (newConfig.LOG_DIR !== undefined) currentConfig.LOG_DIR = newConfig.LOG_DIR;
if (newConfig.LOG_DIR !== undefined) {
const p = String(newConfig.LOG_DIR);
// 防止路径遍历:解析后的绝对路径必须在工作目录内
const resolved = path.resolve(process.cwd(), p);
const cwd = process.cwd();
// 使用 path.relative 和 path.isAbsolute 进行更严格的校验
const relativePath = path.relative(cwd, resolved);
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
// Windows 大小写不敏感兼容:仅在 Windows 平台统一转换为小写比较
const isWindows = process.platform === 'win32';
const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/');
const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).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;
if (newConfig.LOG_INCLUDE_TIMESTAMP !== undefined) currentConfig.LOG_INCLUDE_TIMESTAMP = newConfig.LOG_INCLUDE_TIMESTAMP;
if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE;
if (newConfig.LOG_MAX_FILES !== undefined) currentConfig.LOG_MAX_FILES = newConfig.LOG_MAX_FILES;
// Scheduled Health Check settings
if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) {
const incoming = newConfig.SCHEDULED_HEALTH_CHECK;
// 检测 enabled 状态变化(在更新配置之前保存旧状态)
const prevConfig = currentConfig.SCHEDULED_HEALTH_CHECK || {};
const wasEnabled = prevConfig.enabled === true;
const nowEnabled = incoming?.enabled === true;
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
if (newConfig.systemPrompt !== undefined) {
const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt';
@ -175,7 +309,8 @@ export async function handleUpdateConfig(req, res, currentConfig) {
TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED,
TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS,
TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT,
TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL
TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL,
SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK
};
writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8');
@ -266,17 +401,27 @@ 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 < PASSWORD.MIN_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } }));
return true;
}
// 使用 PBKDF2 哈希存储密码,避免明文写入文件
const salt = crypto.randomBytes(16).toString('hex');
const hash = await new Promise((resolve, reject) =>
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
err ? reject(err) : resolve(key.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,69 @@ import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFro
import { broadcastEvent } from './event-broadcast.js';
import { getRegisteredProviders } from '../providers/adapter.js';
// 文件级互斥锁:防止并发读写导致数据丢失
// 安全净化移除用户输入字段中的危险内容script、事件处理器、javascript:协议等),
// 存储原始文本。HTML 转义统一由前端 escHtml() 负责,避免双编码问题。
function sanitizeProviderData(provider) {
if (!provider || typeof provider !== 'object') return provider;
const sanitized = { ...provider };
if (typeof sanitized.customName === 'string') {
let name = sanitized.customName;
// 拒绝包含危险协议
if (/(?:data|javascript|vbscript)\s*:/i.test(name)) {
sanitized.customName = '';
return sanitized;
}
// 移除所有 HTML 标签(更安全的方式)
name = name.replace(/<[^>]*>/g, '');
// 移除 HTML 事件处理器属性onclick/onerror 等)
name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
// 移除潜在的 HTML 实体编码攻击
name = name.replace(/&[#\w]+;/g, '');
sanitized.customName = name.trim();
}
return sanitized;
}
function sanitizeProviderPools(pools) {
if (!pools || typeof pools !== 'object') return pools;
const sanitized = {};
for (const [type, providers] of Object.entries(pools)) {
sanitized[type] = Array.isArray(providers)
? providers.map(sanitizeProviderData)
: providers;
}
return sanitized;
}
// 使用 Promise 链式队列,确保文件操作顺序执行
let _fileLockChain = Promise.resolve();
// 超时包装函数:防止操作永久挂起导致锁链阻塞
function withTimeout(promise, ms = 30000) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Operation timeout after ${ms}ms`)), ms)
)
]);
}
function withFileLock(fn) {
const next = _fileLockChain
.then(() => withTimeout(fn(), 30000))
.catch(err => {
// 记录错误并抛出,中断操作
logger.error('[FileLock] Operation failed:', err?.message || err);
throw err;
});
_fileLockChain = next.then(() => {}).catch(() => {});
return next;
}
/**
* 获取提供商池摘要
*/
@ -24,7 +87,7 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(providerPools));
res.end(JSON.stringify(sanitizeProviderPools(providerPools)));
return true;
}
@ -59,7 +122,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
providerType,
providers,
providers: providers.map(sanitizeProviderData),
totalCount: providers.length,
healthyCount: providers.filter(p => p.isHealthy).length
}));
@ -93,6 +156,13 @@ export async function handleGetProviderTypeModels(req, res, providerType) {
* 添加新的提供商配置
*/
export async function 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 {
const body = await getRequestBody(req);
const { providerType, providerConfig } = body;
@ -115,7 +185,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
providerConfig.errorCount = providerConfig.errorCount || 0;
providerConfig.lastErrorTime = providerConfig.lastErrorTime || null;
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
let providerPools = {};
// Load existing pools
@ -149,7 +219,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
action: 'add',
filePath: filePath,
providerType,
providerConfig,
providerConfig: sanitizeProviderData(providerConfig),
timestamp: new Date().toISOString()
});
@ -157,7 +227,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
broadcastEvent('provider_update', {
action: 'add',
providerType,
providerConfig,
providerConfig: sanitizeProviderData(providerConfig),
timestamp: new Date().toISOString()
});
@ -165,7 +235,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
res.end(JSON.stringify({
success: true,
message: 'Provider added successfully',
provider: providerConfig,
provider: sanitizeProviderData(providerConfig),
providerType
}));
return true;
@ -180,6 +250,13 @@ 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)).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 {
const body = await getRequestBody(req);
const { providerConfig } = body;
@ -244,7 +321,7 @@ export async function handleUpdateProvider(req, res, currentConfig, providerPool
action: 'update',
filePath: filePath,
providerType,
providerConfig: updatedProvider,
providerConfig: sanitizeProviderData(updatedProvider),
timestamp: new Date().toISOString()
});
@ -252,7 +329,7 @@ export async function handleUpdateProvider(req, res, currentConfig, providerPool
res.end(JSON.stringify({
success: true,
message: 'Provider updated successfully',
provider: updatedProvider
provider: sanitizeProviderData(updatedProvider)
}));
return true;
} catch (error) {
@ -266,6 +343,13 @@ 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)).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 {
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
let providerPools = {};
@ -315,7 +399,7 @@ export async function handleDeleteProvider(req, res, currentConfig, providerPool
action: 'delete',
filePath: filePath,
providerType,
providerConfig: deletedProvider,
providerConfig: sanitizeProviderData(deletedProvider),
timestamp: new Date().toISOString()
});
@ -323,7 +407,7 @@ export async function handleDeleteProvider(req, res, currentConfig, providerPool
res.end(JSON.stringify({
success: true,
message: 'Provider deleted successfully',
deletedProvider
deletedProvider: sanitizeProviderData(deletedProvider)
}));
return true;
} catch (error) {
@ -337,6 +421,13 @@ 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)).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 {
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
let providerPools = {};
@ -388,7 +479,7 @@ export async function handleDisableEnableProvider(req, res, currentConfig, provi
action: action,
filePath: filePath,
providerType,
providerConfig: provider,
providerConfig: sanitizeProviderData(provider),
timestamp: new Date().toISOString()
});
@ -396,7 +487,7 @@ export async function handleDisableEnableProvider(req, res, currentConfig, provi
res.end(JSON.stringify({
success: true,
message: `Provider ${action}d successfully`,
provider: provider
provider: sanitizeProviderData(provider)
}));
return true;
} catch (error) {
@ -550,7 +641,7 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr
filePath: filePath,
providerType,
deletedCount: unhealthyProviders.length,
deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })),
deletedProviders: unhealthyProviders.map(p => sanitizeProviderData({ uuid: p.uuid, customName: p.customName })),
timestamp: new Date().toISOString()
});
@ -641,7 +732,7 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi
filePath: filePath,
providerType,
refreshedCount: refreshedProviders.length,
refreshedProviders,
refreshedProviders: refreshedProviders.map(p => sanitizeProviderData(p)),
timestamp: new Date().toISOString()
});
@ -698,7 +789,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
logger.info(`[UI API] Starting health check for ${unhealthyProviders.length} unhealthy providers in ${providerType} (total: ${providers.length})`);
// 执行健康检测(强制检查,忽略 checkHealth 配置
// 执行健康检测(检查所有未禁用的 unhealthy providers
const results = [];
for (const providerStatus of unhealthyProviders) {
const providerConfig = providerStatus.config;
@ -709,18 +800,8 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
continue;
}
try {
// 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置
const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true);
if (healthResult === null) {
results.push({
uuid: providerConfig.uuid,
success: null,
message: 'Health check not supported for this provider type'
});
continue;
}
try {
const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig);
if (healthResult.success) {
providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName);
@ -797,7 +878,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
action: 'health_check',
filePath: filePath,
providerType,
results,
results: results.map(r => ({ ...r, message: sanitizeProviderData({ message: r.message }).message })),
timestamp: new Date().toISOString()
});
@ -1037,7 +1118,7 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide
message: 'UUID refreshed successfully',
oldUuid,
newUuid,
provider: providerPools[providerType][providerIndex]
provider: sanitizeProviderData(providerPools[providerType][providerIndex])
}));
return true;
} catch (error) {

42
src/utils/constants.js Normal file
View file

@ -0,0 +1,42 @@
/**
* 共享常量定义
* 集中管理各处使用的硬编码值
*/
// 定时健康检查相关常量
export const HEALTH_CHECK = {
// 最小检查间隔60秒60000毫秒
MIN_INTERVAL_MS: 60000,
// 默认检查间隔10分钟600000毫秒
DEFAULT_INTERVAL_MS: 600000,
// 最大检查间隔1小时3600000毫秒- 仅用于前端UI限制
MAX_INTERVAL_MS: 3600000
};
// 密码安全相关常量
export const PASSWORD = {
// 最小密码长度最少12位与现代安全实践一致
MIN_LENGTH: 12,
// PBKDF2迭代次数OWASP 2023建议 SHA-512 ≥310,000次
PBKDF2_ITERATIONS: 310000,
// PBKDF2密钥长度字节
PBKDF2_KEYLEN: 64,
// PBKDF2哈希算法
PBKDF2_DIGEST: 'sha512'
};
// 网络相关常量
export const NETWORK = {
// 最小端口号
MIN_PORT: 1,
// 最大端口号
MAX_PORT: 65535,
// 默认服务器端口
DEFAULT_PORT: 3000
};
// 请求重试相关常量
export const RETRY = {
// 最大重试次数
MAX_RETRIES: 100
};

View file

@ -41,6 +41,7 @@ export const PROVIDER_MAPPINGS = [
providerType: 'openai-qwen-oauth',
credPathKey: 'QWEN_OAUTH_CREDS_FILE_PATH',
defaultCheckModel: 'qwen3-coder-plus',
defaultCheckHealth: true,
displayName: 'Qwen OAuth',
needsProjectId: false,
urlKeys: ['QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL']
@ -93,9 +94,14 @@ export const PROVIDER_MAPPINGS = [
/**
* 生成 UUID
* 兼容旧版 Node.js<14.17.0如果 crypto.randomUUID 不存在则使用 Math.random 回退方案
* @returns {string} UUID 字符串
*/
export function generateUUID() {
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);
@ -324,13 +330,13 @@ export async function isValidOAuthCredentials(filePath) {
* @returns {Object} 新的提供商配置对象
*/
export function createProviderConfig(options) {
const { credPathKey, credPath, defaultCheckModel, needsProjectId, urlKeys } = options;
const { credPathKey, credPath, defaultCheckModel, defaultCheckHealth, needsProjectId, urlKeys } = options;
const newProvider = {
[credPathKey]: credPath,
uuid: generateUUID(),
checkModelName: defaultCheckModel,
checkHealth: false,
checkHealth: defaultCheckHealth ?? false,
isHealthy: true,
isDisabled: false,
lastUsed: null,

View file

@ -32,6 +32,12 @@ function updateConfigProviderConfigs(configs) {
if (tlsSidecarProvidersEl) {
renderProviderTags(tlsSidecarProvidersEl, configs, false);
}
// 渲染定时健康检查的提供商选择
const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders');
if (scheduledHealthCheckProvidersEl) {
renderProviderTags(scheduledHealthCheckProvidersEl, configs, false);
}
// 重新加载当前配置以恢复选中状态
loadConfiguration();
@ -47,10 +53,11 @@ function renderProviderTags(container, configs, isRequired) {
// 过滤掉不可见的提供商
const visibleConfigs = configs.filter(c => c.visible !== false);
const escHtml = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
container.innerHTML = visibleConfigs.map(c => `
<button type="button" class="provider-tag" data-value="${c.id}">
<i class="fas ${c.icon || 'fa-server'}"></i>
<span>${c.name}</span>
<button type="button" class="provider-tag" data-value="${escHtml(c.id)}">
<i class="fas ${escHtml(c.icon || 'fa-server')}"></i>
<span>${escHtml(c.name)}</span>
</button>
`).join('');
@ -151,7 +158,7 @@ async function loadConfiguration() {
if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1;
if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false;
if (loginExpiryEl) loginExpiryEl.value = data.LOGIN_EXPIRY || 3600;
if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH;
if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || '';
if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 10;
if (warmupTargetEl) warmupTargetEl.value = data.WARMUP_TARGET || 0;
if (refreshConcurrencyPerProviderEl) refreshConcurrencyPerProviderEl.value = data.REFRESH_CONCURRENCY_PER_PROVIDER || 1;
@ -236,6 +243,50 @@ async function loadConfiguration() {
});
}
// 定时健康检查配置
const scheduledHealthCheckEnabledEl = document.getElementById('scheduledHealthCheckEnabled');
const scheduledHealthCheckStartupRunEl = document.getElementById('scheduledHealthCheckStartupRun');
const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval');
if (data.SCHEDULED_HEALTH_CHECK) {
if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled === true;
if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = data.SCHEDULED_HEALTH_CHECK.startupRun !== false;
if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = data.SCHEDULED_HEALTH_CHECK.interval || 600000;
} else {
if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = true;
if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = true;
if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = 600000;
}
// 加载定时健康检查的供应商选择
const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders');
if (scheduledHealthCheckProvidersEl) {
const enabledProviders = data.SCHEDULED_HEALTH_CHECK?.providerTypes || [];
const tags = scheduledHealthCheckProvidersEl.querySelectorAll('.provider-tag');
tags.forEach(tag => {
const value = tag.getAttribute('data-value');
if (enabledProviders.includes(value)) {
tag.classList.add('selected');
} else {
tag.classList.remove('selected');
}
});
}
// 定时健康检查间隔快捷按钮(防止重复绑定)
const intervalQuickBtns = document.querySelectorAll('#scheduledHealthCheckInterval + .quick-select-btns button');
intervalQuickBtns.forEach(btn => {
if (btn.dataset.listenerAttached) return; // 防止重复绑定
btn.dataset.listenerAttached = 'true';
btn.addEventListener('click', (e) => {
e.preventDefault();
const value = parseInt(btn.getAttribute('data-value'));
if (scheduledHealthCheckIntervalEl) {
scheduledHealthCheckIntervalEl.value = value;
}
});
});
} catch (error) {
console.error('Failed to load configuration:', error);
}
@ -346,6 +397,24 @@ async function saveConfiguration() {
} else {
config.TLS_SIDECAR_ENABLED_PROVIDERS = [];
}
// 定时健康检查配置
const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders');
const scheduledHealthCheckProviderTypes = scheduledHealthCheckProvidersEl
? Array.from(scheduledHealthCheckProvidersEl.querySelectorAll('.provider-tag.selected'))
.map(tag => tag.getAttribute('data-value'))
: [];
// 验证并规范化 interval 值
const rawInterval = parseInt(document.getElementById('scheduledHealthCheckInterval')?.value);
const validatedInterval = isNaN(rawInterval) ? 600000 : Math.max(60000, Math.min(3600000, rawInterval));
config.SCHEDULED_HEALTH_CHECK = {
enabled: document.getElementById('scheduledHealthCheckEnabled')?.checked !== false,
startupRun: document.getElementById('scheduledHealthCheckStartupRun')?.checked !== false,
interval: validatedInterval,
providerTypes: scheduledHealthCheckProviderTypes
};
try {
await window.apiClient.post('/config', config);

View file

@ -343,6 +343,13 @@ const translations = {
'config.proxy.tlsSidecarProxyUrl': 'Sidecar 上游代理',
'config.proxy.tlsSidecarEnabledProviders': '启用 TLS Sidecar 的提供商',
'config.proxy.tlsSidecarNote': '启用后选中的提供商请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare需重启服务',
'config.healthCheck.title': '定时健康检查',
'config.healthCheck.enabled': '启用定时检查',
'config.healthCheck.startupRun': '启动时运行',
'config.healthCheck.interval': '检查间隔',
'config.healthCheck.intervalNote': '单位毫秒最小60000ms(1分钟)最大3600000ms(1小时),可手动输入或点击快捷按钮',
'config.healthCheck.providerTypes': '定时检查的供应商',
'config.healthCheck.providerTypesNote': '选择需要进行定时健康检查的供应商类型,留空则不进行任何检查',
'config.log.title': '日志设置',
'config.log.enabled': '启用日志',
'config.log.outputMode': '日志输出模式',
@ -1188,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',

View file

@ -7,11 +7,6 @@
border: 1px solid var(--border-color);
}
.config-form {
max-width: 800px;
margin: 0 auto;
}
.form-group {
margin-bottom: 1.5rem;
}
@ -49,6 +44,33 @@ textarea.form-control {
font-family: inherit;
}
/* 带快捷选择的输入框 */
.input-with-quick-select {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-with-quick-select .form-control {
width: 100%;
}
.quick-select-btns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.quick-select-btns .btn {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
}
.config-form {
max-width: 800px;
margin: 0 auto;
}
/* 密码输入框样式 */
.password-input-group {
position: relative;
@ -151,7 +173,7 @@ textarea.form-control {
width: 18px;
left: 3px;
bottom: 2px;
background-color: white;
background-color: var(--bg-primary, white);
transition: var(--transition);
border-radius: 50%;
box-shadow: 0 1px 3px var(--neutral-shadow-30);

View file

@ -62,10 +62,7 @@
<i class="fas fa-reply"></i>
<span>OpenAI Responses</span>
</button>
<button type="button" class="provider-tag" data-value="openai-iflow">
<i class="fas fa-stream"></i>
<span data-i18n="dashboard.routing.nodeName.iflow">iFlow OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
<i class="fas fa-code"></i>
<span data-i18n="dashboard.routing.nodeName.codex">OpenAI Codex OAuth</span>
@ -118,10 +115,7 @@
<i class="fas fa-reply"></i>
<span>OpenAI Responses</span>
</button>
<button type="button" class="provider-tag" data-value="openai-iflow">
<i class="fas fa-stream"></i>
<span>iFlow OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
@ -228,6 +222,45 @@
</div>
</div>
<!-- 定时健康检查 -->
<div class="config-group-section">
<h3 data-i18n="config.healthCheck.title"><i class="fas fa-heartbeat"></i> 定时健康检查</h3>
<div class="config-row">
<div class="form-group">
<span class="toggle-label" data-i18n="config.healthCheck.enabled">启用定时检查</span>
<label class="toggle-switch">
<input type="checkbox" id="scheduledHealthCheckEnabled">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group">
<span class="toggle-label" data-i18n="config.healthCheck.startupRun">启动时运行</span>
<label class="toggle-switch">
<input type="checkbox" id="scheduledHealthCheckStartupRun">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-group">
<label for="scheduledHealthCheckInterval" data-i18n="config.healthCheck.interval">检查间隔</label>
<div class="input-with-quick-select">
<input type="number" id="scheduledHealthCheckInterval" class="form-control" min="60000" max="3600000" step="60000" value="600000" placeholder="毫秒">
<div class="quick-select-btns">
<button type="button" class="btn btn-sm btn-outline-secondary" data-value="300000">5分钟</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-value="600000">10分钟</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-value="1800000">30分钟</button>
</div>
</div>
<small class="form-text" data-i18n="config.healthCheck.intervalNote">单位毫秒最小60000ms(1分钟)最大3600000ms(1小时)</small>
</div>
<div class="form-group">
<label data-i18n="config.healthCheck.providerTypes">定时检查的供应商</label>
<div id="scheduledHealthCheckProviders" class="provider-tags">
<!-- 由 config-manager.js updateConfigProviderConfigs 动态渲染,勿在此处硬编码 -->
</div>
<small class="form-text" data-i18n="config.healthCheck.providerTypesNote">选择需要进行定时健康检查的供应商类型</small>
</div>
</div>
<!-- 日志管理 -->
<div class="config-group-section">

View file

@ -0,0 +1,325 @@
/**
* Security Fixes Integration Test Suite
*
* 测试最近修复的安全问题
* 1. XSS 防护 - sanitizeProviderData
* 2. 路径遍历防护 - 路径验证逻辑
* 3. 文件锁超时机制
* 4. 健康检查方法调用
*/
import { describe, test, expect } from '@jest/globals';
import { fetch } from 'undici';
const TEST_SERVER_BASE_URL = process.env.TEST_SERVER_BASE_URL || 'http://localhost:3000';
const TEST_API_KEY = process.env.TEST_API_KEY || '123456';
describe('Security Fixes Integration Tests', () => {
describe('XSS Protection', () => {
test('should remove script tags from customName', async () => {
const maliciousName = '<script>alert("XSS")</script>TestProvider';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
providerType: 'openai-custom',
providerConfig: {
customName: maliciousName,
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
OPENAI_CUSTOM_API_KEY: 'test-key'
}
})
});
const data = await response.json();
expect(data.provider.customName).not.toContain('<script>');
expect(data.provider.customName).not.toContain('</script>');
expect(data.provider.customName).toContain('TestProvider');
});
test('should reject javascript: protocol', async () => {
const maliciousName = 'javascript:alert("XSS")';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
providerType: 'openai-custom',
providerConfig: {
customName: maliciousName,
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
OPENAI_CUSTOM_API_KEY: 'test-key'
}
})
});
const data = await response.json();
expect(data.provider.customName).toBe('');
});
test('should reject data: protocol', async () => {
const maliciousName = 'data:text/html,<script>alert(1)</script>';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
providerType: 'openai-custom',
providerConfig: {
customName: maliciousName,
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
OPENAI_CUSTOM_API_KEY: 'test-key'
}
})
});
const data = await response.json();
expect(data.provider.customName).toBe('');
});
test('should remove HTML event handlers', async () => {
const maliciousName = '<img src=x onerror="alert(1)">';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
providerType: 'openai-custom',
providerConfig: {
customName: maliciousName,
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
OPENAI_CUSTOM_API_KEY: 'test-key'
}
})
});
const data = await response.json();
expect(data.provider.customName).not.toContain('onerror');
expect(data.provider.customName).not.toContain('<img');
});
test('should remove HTML entities', async () => {
const maliciousName = '&lt;script&gt;alert(1)&lt;/script&gt;';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
providerType: 'openai-custom',
providerConfig: {
customName: maliciousName,
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
OPENAI_CUSTOM_API_KEY: 'test-key'
}
})
});
const data = await response.json();
expect(data.provider.customName).not.toContain('&lt;');
expect(data.provider.customName).not.toContain('&gt;');
});
test('should preserve normal text', async () => {
const normalName = 'My Test Provider 123';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
providerType: 'openai-custom',
providerConfig: {
customName: normalName,
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
OPENAI_CUSTOM_API_KEY: 'test-key'
}
})
});
const data = await response.json();
expect(data.provider.customName).toBe(normalName);
});
});
describe('Path Traversal Protection', () => {
test('should reject paths with ..', async () => {
const maliciousPath = '../../../etc/passwd';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
SYSTEM_PROMPT_FILE_PATH: maliciousPath
})
});
expect(response.status).toBe(200);
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
headers: {
'Authorization': `Bearer ${TEST_API_KEY}`
}
});
const config = await getResponse.json();
expect(config.SYSTEM_PROMPT_FILE_PATH).not.toBe(maliciousPath);
});
test('should accept valid paths within working directory', async () => {
const validPath = 'configs/my_prompt.txt';
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
SYSTEM_PROMPT_FILE_PATH: validPath
})
});
expect(response.status).toBe(200);
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
headers: {
'Authorization': `Bearer ${TEST_API_KEY}`
}
});
const config = await getResponse.json();
expect(config.SYSTEM_PROMPT_FILE_PATH).toBe(validPath);
});
});
describe('Health Check Configuration', () => {
test('should save scheduled health check settings', async () => {
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
SCHEDULED_HEALTH_CHECK: {
enabled: true,
startupRun: true,
interval: 300000,
providerTypes: ['openai-custom']
}
})
});
expect(response.status).toBe(200);
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
headers: {
'Authorization': `Bearer ${TEST_API_KEY}`
}
});
const config = await getResponse.json();
expect(config.SCHEDULED_HEALTH_CHECK).toBeDefined();
expect(config.SCHEDULED_HEALTH_CHECK.enabled).toBe(true);
expect(config.SCHEDULED_HEALTH_CHECK.interval).toBe(300000);
});
test('should enforce minimum interval', async () => {
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
SCHEDULED_HEALTH_CHECK: {
enabled: true,
interval: 30000
}
})
});
expect(response.status).toBe(200);
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
headers: {
'Authorization': `Bearer ${TEST_API_KEY}`
}
});
const config = await getResponse.json();
expect(config.SCHEDULED_HEALTH_CHECK.interval).toBeGreaterThanOrEqual(60000);
});
});
describe('Configuration Validation', () => {
test('should reject invalid port numbers', async () => {
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
SERVER_PORT: 99999
})
});
expect(response.status).toBe(200);
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
headers: {
'Authorization': `Bearer ${TEST_API_KEY}`
}
});
const config = await getResponse.json();
expect(config.SERVER_PORT).not.toBe(99999);
});
test('should reject excessive retry counts', async () => {
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_API_KEY}`
},
body: JSON.stringify({
REQUEST_MAX_RETRIES: 999
})
});
expect(response.status).toBe(200);
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
headers: {
'Authorization': `Bearer ${TEST_API_KEY}`
}
});
const config = await getResponse.json();
expect(config.REQUEST_MAX_RETRIES).toBeLessThanOrEqual(100);
});
test('should mask API key in response', async () => {
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
headers: {
'Authorization': `Bearer ${TEST_API_KEY}`
}
});
const config = await response.json();
if (config.REQUIRED_API_KEY) {
expect(config.REQUIRED_API_KEY).toContain('*');
}
});
});
});

View file

@ -0,0 +1,205 @@
/**
* Unit Tests for Security Fixes
*
* 这些是不需要运行服务器的纯单元测试
* 可以直接运行: npm test -- tests/security-fixes.unit.test.js
*/
import { describe, test, expect } from '@jest/globals';
// ========== 模拟 sanitizeProviderData 函数 ==========
function sanitizeProviderData(provider) {
if (!provider || typeof provider !== 'object') return provider;
const sanitized = { ...provider };
if (typeof sanitized.customName === 'string') {
let name = sanitized.customName;
// 拒绝包含危险协议
if (/(?:data|javascript|vbscript)\s*:/i.test(name)) {
sanitized.customName = '';
return sanitized;
}
// 移除所有 HTML 标签
name = name.replace(/<[^>]*>/g, '');
// 移除 HTML 事件处理器属性
name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
// 移除潜在的 HTML 实体编码攻击
name = name.replace(/&[#\w]+;/g, '');
sanitized.customName = name.trim();
}
return sanitized;
}
// ========== 模拟 withTimeout 函数 ==========
function withTimeout(promise, ms = 30000) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Operation timeout after ${ms}ms`)), ms)
)
]);
}
// ========== 模拟路径验证逻辑 ==========
import path from 'path';
function validatePath(inputPath, cwd) {
const resolved = path.resolve(cwd, inputPath);
const relativePath = path.relative(cwd, resolved);
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
const isWindows = process.platform === 'win32';
const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/');
const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).replace(/\\/g, '/');
const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd;
return isInsideCwd && startsWithCwd;
}
describe('Unit Tests - sanitizeProviderData', () => {
test('should remove script tags', () => {
const input = { customName: '<script>alert("XSS")</script>TestProvider' };
const result = sanitizeProviderData(input);
expect(result.customName).not.toContain('<script>');
expect(result.customName).not.toContain('</script>');
expect(result.customName).toContain('TestProvider');
});
test('should reject javascript: protocol', () => {
const input = { customName: 'javascript:alert("XSS")' };
const result = sanitizeProviderData(input);
expect(result.customName).toBe('');
});
test('should reject data: protocol', () => {
const input = { customName: 'data:text/html,<script>alert(1)</script>' };
const result = sanitizeProviderData(input);
expect(result.customName).toBe('');
});
test('should reject vbscript: protocol', () => {
const input = { customName: 'vbscript:msgbox("XSS")' };
const result = sanitizeProviderData(input);
expect(result.customName).toBe('');
});
test('should remove all HTML tags', () => {
const input = { customName: '<div><img src=x><span>Test</span></div>' };
const result = sanitizeProviderData(input);
expect(result.customName).toBe('Test');
expect(result.customName).not.toContain('<');
expect(result.customName).not.toContain('>');
});
test('should remove event handlers', () => {
const input = { customName: 'Test onclick="alert(1)" Provider' };
const result = sanitizeProviderData(input);
expect(result.customName).not.toContain('onclick');
expect(result.customName).toContain('Test');
expect(result.customName).toContain('Provider');
});
test('should remove HTML entities', () => {
const input = { customName: 'Test&lt;script&gt;Provider&#39;' };
const result = sanitizeProviderData(input);
expect(result.customName).not.toContain('&lt;');
expect(result.customName).not.toContain('&gt;');
expect(result.customName).not.toContain('&#39;');
});
test('should preserve normal text', () => {
const input = { customName: 'My Test Provider 123' };
const result = sanitizeProviderData(input);
expect(result.customName).toBe('My Test Provider 123');
});
test('should handle empty string', () => {
const input = { customName: '' };
const result = sanitizeProviderData(input);
expect(result.customName).toBe('');
});
test('should handle null/undefined', () => {
expect(sanitizeProviderData(null)).toBe(null);
expect(sanitizeProviderData(undefined)).toBe(undefined);
});
test('should handle object without customName', () => {
const input = { uuid: '123', type: 'test' };
const result = sanitizeProviderData(input);
expect(result).toEqual(input);
});
test('should handle complex XSS vectors', () => {
const vectors = [
'<img src=x onerror="alert(1)">',
'<svg onload="alert(1)">',
'<iframe src="javascript:alert(1)">',
'"><script>alert(1)</script>',
];
vectors.forEach(vector => {
const result = sanitizeProviderData({ customName: vector });
expect(result.customName).not.toContain('<script>');
expect(result.customName).not.toContain('onerror');
expect(result.customName).not.toContain('onload');
expect(result.customName).not.toContain('javascript');
});
});
});
describe('Unit Tests - withTimeout', () => {
test('should complete before timeout', async () => {
const fastOperation = Promise.resolve('success');
const result = await withTimeout(fastOperation, 1000);
expect(result).toBe('success');
});
test('should reject slow operations after timeout', async () => {
const slowOperation = new Promise(resolve => setTimeout(() => resolve('done'), 2000));
await expect(withTimeout(slowOperation, 100)).rejects.toThrow('Operation timeout after 100ms');
});
test('should use default timeout of 30 seconds', async () => {
const slowOperation = new Promise(resolve => setTimeout(() => resolve('done'), 35000));
await expect(withTimeout(slowOperation)).rejects.toThrow('Operation timeout after 30000ms');
}, 35000);
test('should propagate operation errors', async () => {
const failingOperation = Promise.reject(new Error('Operation failed'));
await expect(withTimeout(failingOperation, 1000)).rejects.toThrow('Operation failed');
});
});
describe('Unit Tests - Path Validation', () => {
test('should accept paths within working directory', () => {
const cwd = process.platform === 'win32' ? 'C:\\Users\\Test\\Project' : '/home/user/project';
expect(validatePath('configs/test.txt', cwd)).toBe(true);
expect(validatePath('./configs/test.txt', cwd)).toBe(true);
expect(validatePath('test.txt', cwd)).toBe(true);
});
test('should reject paths with directory traversal', () => {
const cwd = process.platform === 'win32' ? 'C:\\Users\\Test\\Project' : '/home/user/project';
expect(validatePath('../../../etc/passwd', cwd)).toBe(false);
expect(validatePath('configs/../../etc/passwd', cwd)).toBe(false);
});
test('should reject absolute paths outside working directory', () => {
const cwd = process.platform === 'win32' ? 'C:\\Users\\Test\\Project' : '/home/user/project';
expect(validatePath('/etc/passwd', cwd)).toBe(false);
expect(validatePath('/tmp/test.txt', cwd)).toBe(false);
});
test('should handle Windows paths correctly', () => {
if (process.platform === 'win32') {
const cwd = 'C:\\Users\\Test\\Project';
expect(validatePath('configs\\test.txt', cwd)).toBe(true);
expect(validatePath('CONFIGS\\TEST.TXT', cwd)).toBe(true);
}
});
});