## Major Features ### Streaming Response System - Implement LlmDriver trait with `stream()` method returning async Stream - Add SSE parsing for Anthropic and OpenAI API streaming - Integrate Tauri event system for frontend streaming (`stream:chunk` events) - Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error ### MCP Protocol Implementation - Add MCP JSON-RPC 2.0 types (mcp_types.rs) - Implement stdio-based MCP transport (mcp_transport.rs) - Support tool discovery, execution, and resource operations ### Browser Hand Implementation - Complete browser automation with Playwright-style actions - Support Navigate, Click, Type, Scrape, Screenshot, Wait actions - Add educational Hands: Whiteboard, Slideshow, Speech, Quiz ### Security Enhancements - Implement command whitelist/blacklist for shell_exec tool - Add SSRF protection with private IP blocking - Create security.toml configuration file ## Test Improvements - Fix test import paths (security-utils, setup) - Fix vi.mock hoisting issues with vi.hoisted() - Update test expectations for validateUrl and sanitizeFilename - Add getUnsupportedLocalGatewayStatus mock ## Documentation Updates - Update architecture documentation - Improve configuration reference - Add quick-start guide updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
/**
|
|
* Security Module Tests
|
|
*
|
|
* Unit tests for crypto utilities, security utils, and audit logging.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
// ============================================================================
|
|
// Crypto Utils Tests
|
|
// ============================================================================
|
|
|
|
describe('Crypto Utils', () => {
|
|
// Import dynamically to handle Web Crypto API
|
|
let cryptoUtils: typeof import('../../src/lib/crypto-utils');
|
|
|
|
beforeEach(async () => {
|
|
cryptoUtils = await import('../../src/lib/crypto-utils');
|
|
});
|
|
|
|
describe('arrayToBase64 / base64ToArray', () => {
|
|
it('should convert Uint8Array to base64 and back', () => {
|
|
const original = new Uint8Array([72, 101, 108, 108, 111]);
|
|
const base64 = cryptoUtils.arrayToBase64(original);
|
|
const decoded = cryptoUtils.base64ToArray(base64);
|
|
|
|
expect(decoded).toEqual(original);
|
|
});
|
|
|
|
it('should handle empty array', () => {
|
|
const empty = new Uint8Array([]);
|
|
expect(cryptoUtils.arrayToBase64(empty)).toBe('');
|
|
expect(cryptoUtils.base64ToArray('')).toEqual(empty);
|
|
});
|
|
});
|
|
|
|
describe('deriveKey', () => {
|
|
it('should derive a CryptoKey from a master key', async () => {
|
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
|
|
|
expect(key).toBeDefined();
|
|
expect(key.type).toBe('secret');
|
|
});
|
|
|
|
it('should derive the same key for the same input', async () => {
|
|
const key1 = await cryptoUtils.deriveKey('test-master-key');
|
|
const key2 = await cryptoUtils.deriveKey('test-master-key');
|
|
|
|
// Both should be valid CryptoKey objects
|
|
expect(key1).toBeDefined();
|
|
expect(key2).toBeDefined();
|
|
});
|
|
|
|
it('should use custom salt', async () => {
|
|
const customSalt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
const key = await cryptoUtils.deriveKey('test-master-key', customSalt);
|
|
|
|
expect(key).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('encrypt / decrypt', () => {
|
|
it('should encrypt and decrypt a string', async () => {
|
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
|
const plaintext = 'Hello, World!';
|
|
|
|
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
|
expect(encrypted.iv).toBeDefined();
|
|
expect(encrypted.data).toBeDefined();
|
|
expect(encrypted.version).toBe(1);
|
|
|
|
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
|
expect(decrypted).toBe(plaintext);
|
|
});
|
|
|
|
it('should produce different IVs for same plaintext', async () => {
|
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
|
const plaintext = 'Same text';
|
|
|
|
const encrypted1 = await cryptoUtils.encrypt(plaintext, key);
|
|
const encrypted2 = await cryptoUtils.encrypt(plaintext, key);
|
|
|
|
expect(encrypted1.iv).not.toBe(encrypted2.iv);
|
|
});
|
|
|
|
it('should handle empty string', async () => {
|
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
|
const plaintext = '';
|
|
|
|
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
|
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
|
|
|
expect(decrypted).toBe('');
|
|
});
|
|
|
|
it('should handle unicode characters', async () => {
|
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
|
const plaintext = '中文测试 日本語 한국어 🎉';
|
|
|
|
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
|
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
|
|
|
expect(decrypted).toBe(plaintext);
|
|
});
|
|
});
|
|
|
|
describe('encryptObject / decryptObject', () => {
|
|
it('should encrypt and decrypt an object', async () => {
|
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
|
const obj = { name: 'test', count: 42, nested: { a: 1 } };
|
|
|
|
const encrypted = await cryptoUtils.encryptObject(obj, key);
|
|
const decrypted = await cryptoUtils.decryptObject<typeof obj>(encrypted, key);
|
|
|
|
expect(decrypted).toEqual(obj);
|
|
});
|
|
});
|
|
|
|
describe('generateMasterKey', () => {
|
|
it('should generate a base64 string', () => {
|
|
const key = cryptoUtils.generateMasterKey();
|
|
|
|
expect(typeof key).toBe('string');
|
|
expect(key.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should generate unique keys', () => {
|
|
const key1 = cryptoUtils.generateMasterKey();
|
|
const key2 = cryptoUtils.generateMasterKey();
|
|
|
|
expect(key1).not.toBe(key2);
|
|
});
|
|
});
|
|
|
|
describe('hashSha256', () => {
|
|
it('should produce consistent hash', async () => {
|
|
const input = 'test-input';
|
|
const hash1 = await cryptoUtils.hashSha256(input);
|
|
const hash2 = await cryptoUtils.hashSha256(input);
|
|
|
|
expect(hash1).toBe(hash2);
|
|
expect(hash1.length).toBe(64); // SHA-256 produces 64 hex chars
|
|
});
|
|
});
|
|
|
|
describe('constantTimeEqual', () => {
|
|
it('should return true for equal arrays', () => {
|
|
const a = new Uint8Array([1, 2, 3, 4]);
|
|
const b = new Uint8Array([1, 2, 3, 4]);
|
|
|
|
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(true);
|
|
});
|
|
|
|
it('should return false for different arrays', () => {
|
|
const a = new Uint8Array([1, 2, 3, 4]);
|
|
const b = new Uint8Array([1, 2, 3, 5]);
|
|
|
|
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
|
|
});
|
|
|
|
it('should return false for different length arrays', () => {
|
|
const a = new Uint8Array([1, 2, 3]);
|
|
const b = new Uint8Array([1, 2, 3, 4]);
|
|
|
|
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isValidEncryptedData', () => {
|
|
it('should validate correct structure', () => {
|
|
const valid = { iv: 'abc', data: 'xyz' };
|
|
expect(cryptoUtils.isValidEncryptedData(valid)).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid structures', () => {
|
|
expect(cryptoUtils.isValidEncryptedData(null)).toBe(false);
|
|
expect(cryptoUtils.isValidEncryptedData({})).toBe(false);
|
|
expect(cryptoUtils.isValidEncryptedData({ iv: '' })).toBe(false);
|
|
expect(cryptoUtils.isValidEncryptedData({ data: '' })).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Security Utils Tests
|
|
// ============================================================================
|
|
|
|
describe('Security Utils', () => {
|
|
let securityUtils: typeof import('../../src/lib/security-utils');
|
|
|
|
beforeEach(async () => {
|
|
securityUtils = await import('../../src/lib/security-utils');
|
|
});
|
|
|
|
describe('escapeHtml', () => {
|
|
it('should escape HTML special characters', () => {
|
|
const input = '<script>alert("xss")</script>';
|
|
const escaped = securityUtils.escapeHtml(input);
|
|
|
|
expect(escaped).toBe('<script>alert("xss")</script>');
|
|
});
|
|
|
|
it('should handle plain text', () => {
|
|
const input = 'Hello, World!';
|
|
expect(securityUtils.escapeHtml(input)).toBe(input);
|
|
});
|
|
|
|
it('should handle ampersand', () => {
|
|
expect(securityUtils.escapeHtml('a & b')).toBe('a & b');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeHtml', () => {
|
|
it('should remove script tags', () => {
|
|
const html = '<p>Hello</p><script>alert("xss")</script>';
|
|
const sanitized = securityUtils.sanitizeHtml(html);
|
|
|
|
expect(sanitized).not.toContain('<script>');
|
|
expect(sanitized).toContain('<p>');
|
|
});
|
|
|
|
it('should remove event handlers', () => {
|
|
const html = '<div onclick="alert(1)">Click me</div>';
|
|
const sanitized = securityUtils.sanitizeHtml(html);
|
|
|
|
expect(sanitized).not.toContain('onclick');
|
|
});
|
|
|
|
it('should remove javascript: URLs', () => {
|
|
const html = '<a href="javascript:alert(1)">Link</a>';
|
|
const sanitized = securityUtils.sanitizeHtml(html);
|
|
|
|
expect(sanitized).not.toContain('javascript:');
|
|
});
|
|
|
|
it('should preserve allowed tags', () => {
|
|
const html = '<p><strong>Bold</strong> and <em>italic</em></p>';
|
|
const sanitized = securityUtils.sanitizeHtml(html);
|
|
|
|
expect(sanitized).toContain('<p>');
|
|
expect(sanitized).toContain('<strong>');
|
|
expect(sanitized).toContain('<em>');
|
|
});
|
|
});
|
|
|
|
describe('validateUrl', () => {
|
|
it('should validate http URLs', () => {
|
|
const url = 'https://example.com/path';
|
|
expect(securityUtils.validateUrl(url)).toBe(url);
|
|
});
|
|
|
|
it('should reject javascript: URLs', () => {
|
|
expect(securityUtils.validateUrl('javascript:alert(1)')).toBeNull();
|
|
});
|
|
|
|
it('should reject data: URLs by default', () => {
|
|
expect(securityUtils.validateUrl('data:text/html,<script>alert(1)</script>')).toBeNull();
|
|
});
|
|
|
|
it('should reject localhost when not allowed', () => {
|
|
expect(
|
|
securityUtils.validateUrl('http://localhost:3000', { allowLocalhost: false })
|
|
).toBeNull();
|
|
});
|
|
|
|
it('should allow localhost when allowed', () => {
|
|
const url = 'http://localhost:3000';
|
|
const result = securityUtils.validateUrl(url, { allowLocalhost: true });
|
|
// URL.toString() may add trailing slash
|
|
expect(result).not.toBeNull();
|
|
expect(result?.startsWith('http://localhost:3000')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('validatePath', () => {
|
|
it('should reject path traversal', () => {
|
|
expect(securityUtils.validatePath('../../../etc/passwd')).toBeNull();
|
|
});
|
|
|
|
it('should reject null bytes', () => {
|
|
expect(securityUtils.validatePath('file\0.txt')).toBeNull();
|
|
});
|
|
|
|
it('should reject absolute paths when not allowed', () => {
|
|
expect(
|
|
securityUtils.validatePath('/etc/passwd', { allowAbsolute: false })
|
|
).toBeNull();
|
|
});
|
|
|
|
it('should validate relative paths', () => {
|
|
const result = securityUtils.validatePath('folder/file.txt');
|
|
expect(result).toBe('folder/file.txt');
|
|
});
|
|
});
|
|
|
|
describe('isValidEmail', () => {
|
|
it('should validate correct emails', () => {
|
|
expect(securityUtils.isValidEmail('test@example.com')).toBe(true);
|
|
expect(securityUtils.isValidEmail('user.name@subdomain.example.com')).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid emails', () => {
|
|
expect(securityUtils.isValidEmail('not-an-email')).toBe(false);
|
|
expect(securityUtils.isValidEmail('@example.com')).toBe(false);
|
|
expect(securityUtils.isValidEmail('test@')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validatePasswordStrength', () => {
|
|
it('should accept strong passwords', () => {
|
|
const result = securityUtils.validatePasswordStrength('Str0ng!Pass');
|
|
expect(result.valid).toBe(true);
|
|
expect(result.score).toBeGreaterThan(50);
|
|
});
|
|
|
|
it('should reject short passwords', () => {
|
|
const result = securityUtils.validatePasswordStrength('short');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.issues).toContain('Password must be at least 8 characters');
|
|
});
|
|
|
|
it('should detect weak patterns', () => {
|
|
const result = securityUtils.validatePasswordStrength('password123');
|
|
expect(result.issues).toContain('Password contains a common pattern');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeFilename', () => {
|
|
it('should remove path separators', () => {
|
|
// Path separators are replaced with _, and leading dots are trimmed to prevent hidden files
|
|
expect(securityUtils.sanitizeFilename('../test.txt')).toBe('_test.txt');
|
|
});
|
|
|
|
it('should remove dangerous characters', () => {
|
|
const sanitized = securityUtils.sanitizeFilename('file<>:"|?*.txt');
|
|
expect(sanitized).not.toMatch(/[<>:"|?*]/);
|
|
});
|
|
|
|
it('should handle normal filenames', () => {
|
|
expect(securityUtils.sanitizeFilename('document.pdf')).toBe('document.pdf');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeJson', () => {
|
|
it('should parse valid JSON', () => {
|
|
const result = securityUtils.sanitizeJson('{"name":"test"}');
|
|
expect(result).toEqual({ name: 'test' });
|
|
});
|
|
|
|
it('should remove prototype pollution keys', () => {
|
|
const result = securityUtils.sanitizeJson('{"__proto__":{"admin":true},"name":"test"}');
|
|
expect(result).not.toHaveProperty('__proto__');
|
|
});
|
|
|
|
it('should return null for invalid JSON', () => {
|
|
expect(securityUtils.sanitizeJson('not json')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isRateLimited', () => {
|
|
beforeEach(() => {
|
|
// Clear rate limit store
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should allow first request', () => {
|
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
|
|
});
|
|
|
|
it('should block after limit reached', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
securityUtils.isRateLimited('test-key', 5, 60000);
|
|
}
|
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
|
|
});
|
|
|
|
it('should reset after window expires', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
securityUtils.isRateLimited('test-key', 5, 60000);
|
|
}
|
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
|
|
|
|
// Advance time past window
|
|
vi.advanceTimersByTime(61000);
|
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('generateSecureToken', () => {
|
|
it('should generate hex string of correct length', () => {
|
|
const token = securityUtils.generateSecureToken(16);
|
|
expect(token.length).toBe(32); // 16 bytes = 32 hex chars
|
|
});
|
|
|
|
it('should generate unique tokens', () => {
|
|
const token1 = securityUtils.generateSecureToken();
|
|
const token2 = securityUtils.generateSecureToken();
|
|
expect(token1).not.toBe(token2);
|
|
});
|
|
});
|
|
|
|
describe('generateSecureId', () => {
|
|
it('should generate ID with prefix', () => {
|
|
const id = securityUtils.generateSecureId('user');
|
|
expect(id.startsWith('user_')).toBe(true);
|
|
});
|
|
|
|
it('should generate ID without prefix', () => {
|
|
const id = securityUtils.generateSecureId();
|
|
expect(id).toMatch(/^\w+_\w+$/);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Security Audit Tests
|
|
// ============================================================================
|
|
|
|
describe('Security Audit', () => {
|
|
let securityAudit: typeof import('../../src/lib/security-audit');
|
|
|
|
beforeEach(async () => {
|
|
securityAudit = await import('../../src/lib/security-audit');
|
|
localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
securityAudit.clearSecurityAuditLog();
|
|
});
|
|
|
|
describe('logSecurityEvent', () => {
|
|
it('should log security events', () => {
|
|
securityAudit.logSecurityEvent('auth_login', 'User logged in', { userId: '123' });
|
|
|
|
const events = securityAudit.getSecurityEvents();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].type).toBe('auth_login');
|
|
expect(events[0].message).toBe('User logged in');
|
|
});
|
|
|
|
it('should determine severity automatically', () => {
|
|
securityAudit.logSecurityEvent('security_violation', 'Test violation');
|
|
securityAudit.logSecurityEvent('auth_login', 'Test login');
|
|
|
|
const events = securityAudit.getSecurityEvents();
|
|
expect(events[0].severity).toBe('critical');
|
|
expect(events[1].severity).toBe('info');
|
|
});
|
|
});
|
|
|
|
describe('getSecurityEventsByType', () => {
|
|
it('should filter events by type', () => {
|
|
securityAudit.logSecurityEvent('auth_login', 'Login 1');
|
|
securityAudit.logSecurityEvent('auth_logout', 'Logout');
|
|
securityAudit.logSecurityEvent('auth_login', 'Login 2');
|
|
|
|
const loginEvents = securityAudit.getSecurityEventsByType('auth_login');
|
|
expect(loginEvents.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getSecurityEventsBySeverity', () => {
|
|
it('should filter events by severity', () => {
|
|
securityAudit.logSecurityEvent('auth_login', 'Login', {}, 'info');
|
|
securityAudit.logSecurityEvent('auth_failed', 'Failed', {}, 'warning');
|
|
|
|
const infoEvents = securityAudit.getSecurityEventsBySeverity('info');
|
|
expect(infoEvents.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('generateSecurityAuditReport', () => {
|
|
it('should generate a report', () => {
|
|
securityAudit.logSecurityEvent('auth_login', 'Login');
|
|
securityAudit.logSecurityEvent('auth_failed', 'Failed');
|
|
securityAudit.logSecurityEvent('security_violation', 'Violation');
|
|
|
|
const report = securityAudit.generateSecurityAuditReport();
|
|
|
|
expect(report.totalEvents).toBe(3);
|
|
expect(report.eventsByType.auth_login).toBe(1);
|
|
expect(report.eventsBySeverity.critical).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('setAuditEnabled', () => {
|
|
it('should disable logging when set to false', () => {
|
|
securityAudit.setAuditEnabled(false);
|
|
securityAudit.logSecurityEvent('auth_login', 'Should not log');
|
|
securityAudit.setAuditEnabled(true);
|
|
|
|
const events = securityAudit.getSecurityEvents();
|
|
// Only the config_changed event should be logged
|
|
const loginEvents = events.filter(e => e.type === 'auth_login');
|
|
expect(loginEvents.length).toBe(0);
|
|
});
|
|
});
|
|
});
|