diff --git a/.gitignore b/.gitignore index f752bb4..c375cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ api-potluck-keys.json api-potluck-data.json # Codex credentials configs/codex/ +AGENTS.md diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6977d13..a9d2eaf 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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: diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 4a323fe..345badf 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -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' }; } diff --git a/src/services/api-manager.js b/src/services/api-manager.js index d9cb526..8cacc09 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -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]; diff --git a/src/services/api-server.js b/src/services/api-server.js index f3b6360..5e0da81 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -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→enabled)config-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; + } } // 如果是子进程,通知主进程已就绪 diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index df3a6a6..b60995f 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -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); } /** diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index eb43add..d39b028 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -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'); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 722ce25..1d4cf3d 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -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) { diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..2ff652c --- /dev/null +++ b/src/utils/constants.js @@ -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 +}; diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 8f1f854..16c1eb7 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -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, diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 68e99cc..8d942e8 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -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,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); container.innerHTML = visibleConfigs.map(c => ` - `).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); diff --git a/static/app/i18n.js b/static/app/i18n.js index 2d1739b..13a06ee 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -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', diff --git a/static/components/section-config.css b/static/components/section-config.css index 6cd16ca..aebf6c4 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -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); diff --git a/static/components/section-config.html b/static/components/section-config.html index 37e0fe1..8308017 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -62,10 +62,7 @@ OpenAI Responses - + - + + + + + + 单位毫秒,最小60000ms(1分钟),最大3600000ms(1小时) + +
+ +
+ +
+ 选择需要进行定时健康检查的供应商类型 +
+
diff --git a/tests/security-fixes.test.js b/tests/security-fixes.test.js new file mode 100644 index 0000000..fe854f3 --- /dev/null +++ b/tests/security-fixes.test.js @@ -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 = '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(''); + 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,'; + 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 = ''; + 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(' { + const maliciousName = '<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).not.toContain('<'); + expect(data.provider.customName).not.toContain('>'); + }); + + 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('*'); + } + }); + }); +}); diff --git a/tests/security-fixes.unit.test.js b/tests/security-fixes.unit.test.js new file mode 100644 index 0000000..d648710 --- /dev/null +++ b/tests/security-fixes.unit.test.js @@ -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: 'TestProvider' }; + const result = sanitizeProviderData(input); + expect(result.customName).not.toContain(''); + 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,' }; + 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: '
Test
' }; + 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<script>Provider'' }; + const result = sanitizeProviderData(input); + expect(result.customName).not.toContain('<'); + expect(result.customName).not.toContain('>'); + expect(result.customName).not.toContain('''); + }); + + 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 = [ + '', + '', + '