From af3915311d14a487ef464c7e0545f017669022cc Mon Sep 17 00:00:00 2001 From: Wenaixi Date: Fri, 3 Apr 2026 02:20:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=8410=E4=B8=AA?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=B8=8E=E6=AD=A3=E7=A1=AE=E6=80=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: provider-pool-manager: 移除 if(true) 占位符,改为读取凭据文件真实过期时间 - fix: provider-pool-manager: Math.min 展开大数组改为 reduce,防止栈溢出 - fix: provider-pool-manager: forceRefreshToken 调用前检查方法是否实现,不存在则 fallback - fix: provider-api: handleAddProvider 默认路径统一为 configs/provider_pools.json - fix: config-api: handleGetConfig 改为白名单字段过滤,REQUIRED_API_KEY 脱敏返回 - fix: api-server: 启动日志中 API Key 遮码处理 - fix: utils: generateUUID 改用 crypto.randomUUID() 替代 Math.random() - fix: config-manager: renderProviderTags innerHTML 加 escHtml 防 XSS 注入 - fix: config-manager: PROVIDER_POOLS_FILE_PATH 未定义时加 || '' 兜底 - fix: section-config.css: white 改为 var(--bg-primary, white) 支持暗黑模式 - chore: .gitignore 添加 AGENTS.md - chore: docker-compose.yml 添加代理环境变量 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..8c08ef3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,12 @@ services: - ./configs:/app/configs environment: - ARGS= + - HTTP_PROXY=http://host.docker.internal:10801 + - http_proxy=http://host.docker.internal:10801 + - HTTPS_PROXY=http://host.docker.internal:10801 + - https_proxy=http://host.docker.internal:10801 + - NO_PROXY=localhost,127.0.0.1,host.docker.internal + - no_proxy=localhost,127.0.0.1,host.docker.internal healthcheck: test: ["CMD", "node", "healthcheck.js"] interval: 30s diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index f039e48..5875f1f 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -112,7 +112,12 @@ 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 = (currentConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000; + const isNearExpiry = expiryTime && (expiryTime - Date.now()) < nearExpiryMs; + if (isNearExpiry) { this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); this._enqueueRefresh(providerType, providerStatus); } @@ -389,7 +394,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 +466,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); @@ -1819,14 +1833,14 @@ export class ProviderPoolManager { continue; } - const checkStartTime = Date.now(); + 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() - checkStartTime; + const checkDuration = Date.now() - providerCheckStart; if (!result.success) { // Provider is unhealthy @@ -1840,7 +1854,7 @@ export class ProviderPoolManager { this.markProviderHealthy(providerType, provider.config, false, result.modelName); } } catch (error) { - const checkDuration = Date.now() - checkStartTime; + const checkDuration = Date.now() - providerCheckStart; failCount++; this._log('error', `[ScheduledHealthCheck] ${displayName} (${providerType}) EXCEPTION: ${error.message} (${checkDuration}ms)`); this.markProviderUnhealthyImmediately(providerType, provider.config, error.message); diff --git a/src/services/api-server.js b/src/services/api-server.js index ad9bdc9..c1a850f 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -313,7 +313,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}`); @@ -355,14 +355,14 @@ async function startServer() { setInterval(heartbeatAndRefreshToken, CONFIG.CRON_NEAR_MINUTES * 60 * 1000); } // 服务器完全启动后,执行初始健康检查 - const poolManager = getProviderPoolManager(); - if (poolManager) { - logger.info('[Initialization] Performing initial health checks for provider pools...'); - poolManager.performHealthChecks(); - } + const poolManager = getProviderPoolManager(); + if (poolManager) { + logger.info('[Initialization] Performing initial health checks for provider pools...'); + poolManager.performHealthChecks(); + } // 定时健康检查 - const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; + const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; if (scheduledConfig?.enabled) { // 设计决策:只验证最小值 60000ms,不设最大值。 // 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index 92c4dac..f6f2b0e 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -57,11 +57,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; } @@ -129,8 +166,10 @@ export async function handleUpdateConfig(req, res, currentConfig) { providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] }; - // 如果定时器已存在且 enabled,重新加载 timer(interval 变化时) - if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled) { + // 如果定时器已存在且 enabled,仅在 interval 实际变化时重新加载 timer + const previousInterval = currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval; + if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== previousInterval) { + currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval = newInterval; globalThis.reloadHealthCheckTimer(newInterval); } } diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 2789de3..a2a02fc 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -115,7 +115,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 diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index 905b8e8..d7e9134 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -97,11 +97,7 @@ export const PROVIDER_MAPPINGS = [ * @returns {string} UUID 字符串 */ export function generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + return crypto.randomUUID(); } /** diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 130bfdc..36ac1cb 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -53,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,'"'); container.innerHTML = visibleConfigs.map(c => ` - `).join(''); @@ -157,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; @@ -248,7 +249,7 @@ async function loadConfiguration() { const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval'); if (data.SCHEDULED_HEALTH_CHECK) { - if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled !== false; + 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 { diff --git a/static/components/section-config.css b/static/components/section-config.css index e16338b..aebf6c4 100644 --- a/static/components/section-config.css +++ b/static/components/section-config.css @@ -173,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); --- tests/security-fixes.test.js | 325 ++++++++++++++++++++++++++++++ tests/security-fixes.unit.test.js | 205 +++++++++++++++++++ 2 files changed, 530 insertions(+) create mode 100644 tests/security-fixes.test.js create mode 100644 tests/security-fixes.unit.test.js 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 = [ + '', + '', + '