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:
iven
2026-03-21 17:27:56 +08:00
parent d266a1435f
commit 32c9f93a7b
2 changed files with 223 additions and 4 deletions

View File

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

View 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');
}
});
});
});