From 32c9f93a7bcfe54e5abc3d05fc08b604309f8613 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 21 Mar 2026 17:27:56 +0800 Subject: [PATCH] feat(security): enforce WSS for non-localhost connections - Add SecurityError class for clear error handling - Add validateWebSocketSecurity function - Block ws:// connections to non-localhost hosts - Add unit tests for security validation logic Security: Prevents man-in-the-middle attacks on remote connections by requiring WSS protocol for all non-localhost WebSocket connections. Co-Authored-By: Claude Opus 4.6 --- desktop/src/lib/gateway-client.ts | 35 +++- desktop/tests/lib/gateway-security.test.ts | 192 +++++++++++++++++++++ 2 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 desktop/tests/lib/gateway-security.test.ts diff --git a/desktop/src/lib/gateway-client.ts b/desktop/src/lib/gateway-client.ts index 3bcff0e..012ca18 100644 --- a/desktop/src/lib/gateway-client.ts +++ b/desktop/src/lib/gateway-client.ts @@ -74,6 +74,35 @@ import { import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config'; import { installApiMethods } from './gateway-api'; +// === Security === + +/** + * Security error for invalid WebSocket connections. + * Thrown when non-localhost URLs use ws:// instead of wss://. + */ +export class SecurityError extends Error { + constructor(message: string) { + super(message); + this.name = 'SecurityError'; + } +} + +/** + * Validate WebSocket URL security. + * Ensures non-localhost connections use WSS protocol. + * + * @param url - The WebSocket URL to validate + * @throws SecurityError if non-localhost URL uses ws:// instead of wss:// + */ +export function validateWebSocketSecurity(url: string): void { + if (!url.startsWith('wss://') && !isLocalhost(url)) { + throw new SecurityError( + 'Non-localhost connections must use WSS protocol for security. ' + + `URL: ${url.replace(/:[^:@]+@/, ':****@')}` + ); + } +} + function createIdempotencyKey(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -205,10 +234,8 @@ export class GatewayClient { return this.connectRest(); } - // Security warning: non-localhost with insecure WebSocket - if (!this.url.startsWith('wss://') && !isLocalhost(this.url)) { - console.warn('[Gateway] Connecting to non-localhost with insecure WebSocket (ws://). Consider using WSS in production.'); - } + // Security validation: enforce WSS for non-localhost connections + validateWebSocketSecurity(this.url); this.autoReconnect = true; this.setState('connecting'); diff --git a/desktop/tests/lib/gateway-security.test.ts b/desktop/tests/lib/gateway-security.test.ts new file mode 100644 index 0000000..07b9d3b --- /dev/null +++ b/desktop/tests/lib/gateway-security.test.ts @@ -0,0 +1,192 @@ +/** + * 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'); + } + }); + }); +});