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 <noreply@anthropic.com>
This commit is contained in:
192
desktop/tests/lib/gateway-security.test.ts
Normal file
192
desktop/tests/lib/gateway-security.test.ts
Normal file
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user