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:
@@ -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');
|
||||
|
||||
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