/** * Security tests for gateway-client.ts * * Tests WSS enforcement for non-localhost connections * to prevent man-in-the-middle attacks. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock WebSocket class MockWebSocket { url: string; readyState: number = WebSocket.CONNECTING; onopen: (() => void) | null = null; onerror: ((error: Error) => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onclose: ((event: { code: number; reason: string }) => void) | null = null; constructor(url: string) { this.url = url; setTimeout(() => { if (this.onopen) this.onopen(); }, 0); } close(code?: number, reason?: string) { this.readyState = WebSocket.CLOSED; if (this.onclose && code !== undefined) { this.onclose({ code, reason: reason || '' }); } } send(_data: string) { // Mock send } } // Stub WebSocket globally vi.stubGlobal('WebSocket', MockWebSocket); describe('WebSocket Security', () => { describe('isLocalhost', () => { it('should identify localhost URLs using the actual isLocalhost function', async () => { const { isLocalhost } = await import('../../src/lib/gateway-storage'); const localhostUrls = [ 'ws://localhost:4200', 'ws://127.0.0.1:4200', 'ws://[::1]:4200', 'wss://localhost:4200', 'wss://127.0.0.1:50051', ]; localhostUrls.forEach(url => { expect(isLocalhost(url)).toBe(true); }); }); it('should identify non-localhost URLs', () => { const remoteUrls = [ 'ws://example.com:4200', 'ws://192.168.1.1:4200', 'wss://api.example.com/ws', 'ws://10.0.0.1:4200', ]; remoteUrls.forEach(url => { const parsed = new URL(url); const isLocal = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname); expect(isLocal).toBe(false); }); }); }); describe('WSS enforcement', () => { it('should allow ws:// for localhost', async () => { const { isLocalhost } = await import('../../src/lib/gateway-storage'); const url = 'ws://localhost:4200'; const isSecure = url.startsWith('wss://') || isLocalhost(url); expect(isSecure).toBe(true); }); it('should reject ws:// for non-localhost (192.168.x.x)', async () => { const { isLocalhost } = await import('../../src/lib/gateway-storage'); const url = 'ws://192.168.1.1:4200'; const isSecure = url.startsWith('wss://') || isLocalhost(url); expect(isSecure).toBe(false); }); it('should reject ws:// for non-localhost (domain)', async () => { const { isLocalhost } = await import('../../src/lib/gateway-storage'); const url = 'ws://example.com:4200'; const isSecure = url.startsWith('wss://') || isLocalhost(url); expect(isSecure).toBe(false); }); it('should allow wss:// for any host', () => { const urls = [ 'wss://example.com:4200', 'wss://api.example.com/ws', 'wss://192.168.1.1:4200', 'wss://10.0.0.1:4200', ]; urls.forEach(url => { expect(url.startsWith('wss://')).toBe(true); }); }); it('should allow wss:// for localhost too', () => { const urls = [ 'wss://localhost:4200', 'wss://127.0.0.1:50051', ]; urls.forEach(url => { expect(url.startsWith('wss://')).toBe(true); }); }); }); describe('SecurityError', () => { // Import SecurityError dynamically to test it it('should be throwable with a message', async () => { const { SecurityError } = await import('../../src/lib/gateway-client'); const error = new SecurityError('Test security error'); expect(error).toBeInstanceOf(Error); expect(error.name).toBe('SecurityError'); expect(error.message).toBe('Test security error'); }); it('should be catchable as Error', async () => { const { SecurityError } = await import('../../src/lib/gateway-client'); try { throw new SecurityError('Test error'); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).name).toBe('SecurityError'); } }); }); describe('validateWebSocketSecurity', () => { it('should not throw for ws://localhost URLs', async () => { const { validateWebSocketSecurity } = await import('../../src/lib/gateway-client'); expect(() => validateWebSocketSecurity('ws://localhost:4200/ws')).not.toThrow(); expect(() => validateWebSocketSecurity('ws://127.0.0.1:50051/ws')).not.toThrow(); expect(() => validateWebSocketSecurity('ws://[::1]:4200/ws')).not.toThrow(); }); it('should not throw for wss:// any URLs', async () => { const { validateWebSocketSecurity } = await import('../../src/lib/gateway-client'); expect(() => validateWebSocketSecurity('wss://example.com:4200/ws')).not.toThrow(); expect(() => validateWebSocketSecurity('wss://192.168.1.1:4200/ws')).not.toThrow(); expect(() => validateWebSocketSecurity('wss://api.example.com/ws')).not.toThrow(); }); it('should throw SecurityError for ws:// non-localhost URLs', async () => { const { validateWebSocketSecurity, SecurityError } = await import('../../src/lib/gateway-client'); expect(() => validateWebSocketSecurity('ws://example.com:4200/ws')) .toThrow(SecurityError); expect(() => validateWebSocketSecurity('ws://192.168.1.1:4200/ws')) .toThrow(SecurityError); }); it('should include sanitized URL in error message', async () => { const { validateWebSocketSecurity, SecurityError } = await import('../../src/lib/gateway-client'); try { validateWebSocketSecurity('ws://example.com:4200/ws'); expect.fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(SecurityError); const err = error as SecurityError; expect(err.message).toContain('Non-localhost'); expect(err.message).toContain('WSS'); } }); }); });