Implement WebRTC real-time call analysis with security hardening (FRE-4497)
- signaling-server.ts: JWT auth, origin validation, JSON schema validation, crypto.randomBytes peer IDs, message size limits, idle timeout, graceful shutdown - alert-server.ts: JWT auth enabled by default, non-empty jwtSecret from env, origin allowlist, per-subscriber callId filtering, bounded alert history with TTL, alert cooldown, graceful shutdown with timeout - call-analysis-engine.ts: Bounded eventBuffer/anomalyBuffer with FIFO eviction, real quality metrics from signal properties, configurable buffer sizes - audio-stream-capture.ts: Proper destroy() lifecycle with awaited stop(), AudioWorklet support with ScriptProcessorNode fallback, bounded frame buffers - Added ws dependency and server tsconfig Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
434
server/webrtc/signaling-server.ts
Normal file
434
server/webrtc/signaling-server.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { WebSocketServer, WebSocket, Data } from 'ws';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { IncomingMessage } from 'http';
|
||||
|
||||
/**
|
||||
* WebRTC Signaling Server
|
||||
* Handles offer/answer/ICE candidate exchange for P2P connections.
|
||||
*
|
||||
* Security hardening (FRE-4497):
|
||||
* - JWT authentication required on WebSocket upgrade
|
||||
* - Origin allowlist validation
|
||||
* - JSON schema validation for all messages
|
||||
* - Server-side peer identity (crypto.randomBytes)
|
||||
* - Message size limits to prevent DoS
|
||||
* - Connection timeout for idle peers
|
||||
*/
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SignalingServerConfig {
|
||||
port: number;
|
||||
host: string;
|
||||
allowedOrigins: string[];
|
||||
jwtSecret: string;
|
||||
maxMessageSize: number;
|
||||
idleTimeoutMs: number;
|
||||
maxConnectionsPerPeer: number;
|
||||
}
|
||||
|
||||
export interface SignalingMessage {
|
||||
type: 'offer' | 'answer' | 'ice-candidate' | 'ping' | 'pong' | 'close';
|
||||
payload?: Record<string, unknown>;
|
||||
targetPeerId?: string;
|
||||
}
|
||||
|
||||
export interface PeerConnection {
|
||||
ws: WebSocket;
|
||||
peerId: string;
|
||||
authenticatedUserId: string;
|
||||
connections: Map<string, PeerSession>;
|
||||
lastActivity: number;
|
||||
iceCandidates: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface PeerSession {
|
||||
targetPeerId: string;
|
||||
dataChannelReady: boolean;
|
||||
bufferedCandidates: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_CONFIG: SignalingServerConfig = {
|
||||
port: parseInt(process.env.SIGNALING_PORT || '3001', 10),
|
||||
host: process.env.SIGNALING_HOST || '0.0.0.0',
|
||||
allowedOrigins: (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean),
|
||||
jwtSecret: process.env.JWT_SECRET || randomBytes(32).toString('hex'),
|
||||
maxMessageSize: 65536,
|
||||
idleTimeoutMs: 300_000,
|
||||
maxConnectionsPerPeer: 10,
|
||||
};
|
||||
|
||||
// Message schema validators
|
||||
const MESSAGE_TYPES = new Set(['offer', 'answer', 'ice-candidate', 'ping', 'pong', 'close']);
|
||||
|
||||
function validateMessage(raw: unknown): raw is SignalingMessage {
|
||||
if (typeof raw !== 'object' || raw === null) return false;
|
||||
const msg = raw as Record<string, unknown>;
|
||||
if (!MESSAGE_TYPES.has(msg.type as string)) return false;
|
||||
if (msg.payload && typeof msg.payload !== 'object') return false;
|
||||
if (msg.targetPeerId !== undefined && typeof msg.targetPeerId !== 'string') return false;
|
||||
if (msg.targetPeerId && msg.targetPeerId.length > 64) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── JWT Helper (lightweight, no external dep) ────────────────────────────────
|
||||
|
||||
function extractJwtFromQuery(url: string): string | null {
|
||||
const match = url.match(/[?&]token=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function extractJwtFromHeader(req: IncomingMessage): string | null {
|
||||
const auth = req.headers['authorization'];
|
||||
return auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal JWT verification (HS256). In production, use jsonwebtoken.
|
||||
* Returns decoded payload or null on failure.
|
||||
*/
|
||||
function verifyJwt(token: string, secret: string): { sub: string; exp: number } | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
|
||||
if (header.alg !== 'HS256') return null;
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
if (!payload.sub || typeof payload.sub !== 'string') return null;
|
||||
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
|
||||
const sigInput = `${parts[0]}.${parts[1]}`;
|
||||
const crypto = require('crypto');
|
||||
const expected = crypto.createHmac('sha256', secret).update(sigInput).digest('base64url');
|
||||
if (expected !== parts[2]) return null;
|
||||
return { sub: payload.sub, exp: payload.exp || 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export class SignalingServer {
|
||||
private wss: WebSocketServer;
|
||||
private peers: Map<string, PeerConnection> = new Map();
|
||||
private config: SignalingServerConfig;
|
||||
private idleTimers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(config: Partial<SignalingServerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.wss = new WebSocketServer({
|
||||
port: this.config.port,
|
||||
host: this.config.host,
|
||||
maxPayload: this.config.maxMessageSize,
|
||||
verifyClient: this.verifyClient.bind(this),
|
||||
});
|
||||
this.wss.on('connection', this.handleConnection.bind(this));
|
||||
console.log(`[Signaling] Server listening on ${this.config.host}:${this.config.port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify incoming WebSocket connection: origin + auth
|
||||
*/
|
||||
private verifyClient(info: { req: IncomingMessage; origin: string }, cb: (result: boolean, status?: number, reason?: string) => void) {
|
||||
// Origin validation
|
||||
if (this.config.allowedOrigins.length > 0) {
|
||||
const origin = info.origin || info.req.headers['origin'] || '';
|
||||
const allowed = this.config.allowedOrigins.some(
|
||||
allowedOrigin => origin === allowedOrigin || origin.startsWith(allowedOrigin)
|
||||
);
|
||||
if (!allowed) {
|
||||
cb(false, 403, `Origin "${origin}" not in allowlist`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// JWT authentication
|
||||
const token = extractJwtFromQuery(info.req.url || '') || extractJwtFromHeader(info.req);
|
||||
if (!token) {
|
||||
cb(false, 401, 'Missing JWT token');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = verifyJwt(token, this.config.jwtSecret);
|
||||
if (!payload) {
|
||||
cb(false, 401, 'Invalid or expired JWT');
|
||||
return;
|
||||
}
|
||||
|
||||
cb(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new WebSocket connection
|
||||
*/
|
||||
private handleConnection(ws: WebSocket, req: IncomingMessage) {
|
||||
const token = extractJwtFromQuery(req.url || '') || extractJwtFromHeader(req);
|
||||
const payload = token ? verifyJwt(token, this.config.jwtSecret) : null;
|
||||
const authenticatedUserId = payload?.sub || '';
|
||||
|
||||
// Server-side peer identity (crypto random)
|
||||
const peerId = `peer_${randomBytes(8).toString('hex')}`;
|
||||
|
||||
const peer: PeerConnection = {
|
||||
ws,
|
||||
peerId,
|
||||
authenticatedUserId,
|
||||
connections: new Map(),
|
||||
lastActivity: Date.now(),
|
||||
iceCandidates: [],
|
||||
};
|
||||
|
||||
this.peers.set(peerId, peer);
|
||||
|
||||
// Send handshake with assigned peer ID
|
||||
ws.send(JSON.stringify({
|
||||
type: 'handshake',
|
||||
payload: { peerId, message: 'Connected' },
|
||||
}));
|
||||
|
||||
// Idle timeout
|
||||
const timer = setTimeout(() => {
|
||||
if (Date.now() - peer.lastActivity > this.config.idleTimeoutMs) {
|
||||
ws.close(1001, 'Idle timeout');
|
||||
}
|
||||
}, this.config.idleTimeoutMs);
|
||||
this.idleTimers.set(peerId, timer);
|
||||
|
||||
ws.on('message', this.handleMessage(peer).bind(this));
|
||||
ws.on('close', () => this.handleDisconnect(peer));
|
||||
ws.on('error', (err) => {
|
||||
console.error(`[Signaling] Peer ${peerId} error:`, err.message);
|
||||
this.handleDisconnect(peer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from peer
|
||||
*/
|
||||
private handleMessage(peer: PeerConnection) {
|
||||
return (data: Data) => {
|
||||
peer.lastActivity = Date.now();
|
||||
|
||||
// Parse with size guard
|
||||
let raw: unknown;
|
||||
try {
|
||||
const str = data.toString();
|
||||
if (str.length > this.config.maxMessageSize) {
|
||||
peer.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Message too large' } }));
|
||||
return;
|
||||
}
|
||||
raw = JSON.parse(str);
|
||||
} catch {
|
||||
peer.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Schema validation
|
||||
if (!validateMessage(raw)) {
|
||||
peer.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid message schema' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = raw as SignalingMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ping':
|
||||
peer.ws.send(JSON.stringify({ type: 'pong', payload: { timestamp: Date.now() } }));
|
||||
break;
|
||||
case 'offer':
|
||||
this.handleOffer(peer, msg);
|
||||
break;
|
||||
case 'answer':
|
||||
this.handleAnswer(peer, msg);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
this.handleIceCandidate(peer, msg);
|
||||
break;
|
||||
case 'close':
|
||||
peer.ws.close(1000, 'Peer requested close');
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route offer to target peer
|
||||
*/
|
||||
private handleOffer(source: PeerConnection, msg: SignalingMessage) {
|
||||
const targetId = msg.targetPeerId;
|
||||
if (!targetId) {
|
||||
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing targetPeerId' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce max connections
|
||||
if (source.connections.size >= this.config.maxConnectionsPerPeer) {
|
||||
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Max connections reached' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.peers.get(targetId);
|
||||
if (!target) {
|
||||
source.ws.send(JSON.stringify({ type: 'error', payload: { message: `Peer ${targetId} not found` } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Register session
|
||||
const session: PeerSession = {
|
||||
targetPeerId: targetId,
|
||||
dataChannelReady: false,
|
||||
bufferedCandidates: [...source.iceCandidates],
|
||||
};
|
||||
source.connections.set(targetId, session);
|
||||
target.connections.set(source.peerId, {
|
||||
targetPeerId: source.peerId,
|
||||
dataChannelReady: false,
|
||||
bufferedCandidates: [],
|
||||
});
|
||||
|
||||
// Forward offer to target
|
||||
target.ws.send(JSON.stringify({
|
||||
type: 'offer',
|
||||
payload: msg.payload,
|
||||
targetPeerId: source.peerId,
|
||||
}));
|
||||
|
||||
// Send buffered ICE candidates if data channel is ready
|
||||
if (session.dataChannelReady) {
|
||||
for (const candidate of session.bufferedCandidates) {
|
||||
target.ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: candidate,
|
||||
targetPeerId: source.peerId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route answer to target peer
|
||||
*/
|
||||
private handleAnswer(source: PeerConnection, msg: SignalingMessage) {
|
||||
const targetId = msg.targetPeerId;
|
||||
if (!targetId) {
|
||||
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing targetPeerId' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.peers.get(targetId);
|
||||
if (!target) {
|
||||
source.ws.send(JSON.stringify({ type: 'error', payload: { message: `Peer ${targetId} not found` } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark data channel as ready for buffered candidate delivery
|
||||
const session = source.connections.get(targetId);
|
||||
if (session) {
|
||||
session.dataChannelReady = true;
|
||||
}
|
||||
|
||||
target.ws.send(JSON.stringify({
|
||||
type: 'answer',
|
||||
payload: msg.payload,
|
||||
targetPeerId: source.peerId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Route ICE candidate to target peer
|
||||
*/
|
||||
private handleIceCandidate(source: PeerConnection, msg: SignalingMessage) {
|
||||
const targetId = msg.targetPeerId;
|
||||
if (!targetId) {
|
||||
source.ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing targetPeerId' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = msg.payload as Record<string, unknown> | undefined;
|
||||
if (!candidate) return;
|
||||
|
||||
// Buffer candidate if target session not ready yet
|
||||
const session = source.connections.get(targetId);
|
||||
if (session && !session.dataChannelReady) {
|
||||
source.iceCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.peers.get(targetId);
|
||||
if (!target) return;
|
||||
|
||||
target.ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: candidate,
|
||||
targetPeerId: source.peerId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle peer disconnect
|
||||
*/
|
||||
private handleDisconnect(peer: PeerConnection) {
|
||||
// Notify connected peers
|
||||
for (const [targetId, session] of peer.connections) {
|
||||
const target = this.peers.get(targetId);
|
||||
if (target) {
|
||||
target.ws.send(JSON.stringify({
|
||||
type: 'close',
|
||||
payload: { peerId: peer.peerId, reason: 'Remote peer disconnected' },
|
||||
targetPeerId: peer.peerId,
|
||||
}));
|
||||
target.connections.delete(peer.peerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear idle timer
|
||||
const timer = this.idleTimers.get(peer.peerId);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.idleTimers.delete(peer.peerId);
|
||||
|
||||
this.peers.delete(peer.peerId);
|
||||
console.log(`[Signaling] Peer ${peer.peerId} disconnected`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown with timeout
|
||||
*/
|
||||
async stop(timeoutMs = 5000): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
for (const [peerId, peer] of this.peers) {
|
||||
const remaining = Math.max(100, deadline - Date.now());
|
||||
setTimeout(() => {
|
||||
peer.ws.close(1001, 'Server shutting down');
|
||||
}, remaining);
|
||||
}
|
||||
|
||||
const serverTimer = setTimeout(() => {
|
||||
this.wss.close();
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
this.wss.close(() => {
|
||||
clearTimeout(serverTimer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server stats
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
connectedPeers: this.peers.size,
|
||||
totalConnections: Array.from(this.peers.values()).reduce((sum, p) => sum + p.connections.size, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createSignalingServer(config?: Partial<SignalingServerConfig>): SignalingServer {
|
||||
return new SignalingServer(config);
|
||||
}
|
||||
Reference in New Issue
Block a user