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 = [ + '', + '', + '