Two isolated hardenings of the opt-in remote/diagnostics WS server, surfaced by the session-wide diagnostics audit: 1. WebSocketServer was created with no maxPayload, so ws defaults to 100 MiB per message. The connection handler runs JSON.parse(raw) on the FIRST message (the auth frame) before authentication, so any peer past the IP allowlist could send a huge payload and force a synchronous multi-MB JSON.parse on the main-process event loop — an unbounded freeze/DoS sink. Diag, auth and WebRTC signaling messages are all small; cap maxPayload at 256 KiB to close it. 2. sendToClient did ws.send(JSON.stringify(data)) with no readyState/try guard (unlike broadcast, which checks ws.readyState === 1). A send on a closing socket, or a stringify throw, escaped the diag-response callback as an uncaughtException — a potential crash. Mirror broadcast: send only when readyState === 1, wrapped in try/catch. Both are isolated to the transport layer with zero redaction surface. The audit's larger finding — server_health doing O(historySize) synchronous work per request (6-7 full-config clones + unbounded history walks) — is a real freeze, but ONLY on the cold opt-in diagnostics path with a large history (this user: 23 rows), and the safe fix cuts into the credential-redaction collectors (which have leaked twice); deferred and documented in tasks/todo.md rather than operated under risk. 397/397 tests pass, eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
5.8 KiB
JavaScript
217 lines
5.8 KiB
JavaScript
const { WebSocketServer } = require('ws');
|
|
const crypto = require('crypto');
|
|
const { evaluateClientAllowed } = require('./ip-allowlist');
|
|
|
|
function timingSafeEqualStr(a, b) {
|
|
const x = Buffer.from(String(a == null ? '' : a));
|
|
const y = Buffer.from(String(b == null ? '' : b));
|
|
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
}
|
|
|
|
class RemoteServer {
|
|
constructor() {
|
|
this._wss = null;
|
|
this._clients = new Map(); // ws -> { id, role, authenticated }
|
|
this._config = null;
|
|
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
|
|
this._lastAccess = null;
|
|
}
|
|
|
|
start(opts) {
|
|
return new Promise((resolve, reject) => {
|
|
this._config = opts;
|
|
|
|
const wssOpts = { port: opts.port, maxPayload: 256 * 1024 };
|
|
if (opts.host) wssOpts.host = opts.host;
|
|
this._wss = new WebSocketServer(wssOpts, () => {
|
|
resolve();
|
|
});
|
|
|
|
this._wss.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
this._wss.on('connection', (ws, req) => {
|
|
this._handleConnection(ws, req);
|
|
});
|
|
});
|
|
}
|
|
|
|
stop() {
|
|
if (this._wss) {
|
|
for (const [ws] of this._clients) {
|
|
ws.close(1000, 'Server shutting down');
|
|
}
|
|
this._clients.clear();
|
|
this._wss.close();
|
|
this._wss = null;
|
|
}
|
|
}
|
|
|
|
getClientCount() {
|
|
let count = 0;
|
|
for (const [, client] of this._clients) {
|
|
if (client.authenticated) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
getPort() {
|
|
if (this._wss && this._wss.address()) {
|
|
return this._wss.address().port;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_handleConnection(ws, req) {
|
|
const ip = req.socket.remoteAddress || 'unknown';
|
|
|
|
if (this._isBlocked(ip)) {
|
|
ws.close(4003, 'Too many failed attempts');
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(this._config.allowlist) && !evaluateClientAllowed(ip, this._config.allowlist)) {
|
|
ws.close(4005, 'Client IP not allowed');
|
|
return;
|
|
}
|
|
|
|
const clientId = crypto.randomUUID();
|
|
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
|
|
|
|
let authReceived = false;
|
|
const authTimeout = setTimeout(() => {
|
|
if (!authReceived) {
|
|
ws.close(4001, 'Auth timeout');
|
|
this._clients.delete(ws);
|
|
}
|
|
}, 5000);
|
|
|
|
ws.on('message', (raw) => {
|
|
let msg;
|
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
|
|
const client = this._clients.get(ws);
|
|
if (!client) return;
|
|
|
|
if (!client.authenticated) {
|
|
authReceived = true;
|
|
clearTimeout(authTimeout);
|
|
|
|
if (msg.type === 'auth' && timingSafeEqualStr(msg.token, this._config.token)) {
|
|
client.authenticated = true;
|
|
client.role = this._config.diagnosticMode ? 'diagnostic' : (msg.role || 'viewer');
|
|
this._lastAccess = Date.now();
|
|
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
|
|
|
|
if (!this._config.diagnosticMode && this.getClientCount() === 1) {
|
|
this._config.onCreateCaptureWindow();
|
|
}
|
|
} else {
|
|
this._recordFailedAttempt(ip);
|
|
ws.close(4002, 'Invalid token');
|
|
this._clients.delete(ws);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this._config.diagnosticMode) {
|
|
if (msg.type === 'diag-request' && typeof this._config.onDiagnosticRequest === 'function') {
|
|
this._lastAccess = Date.now();
|
|
this._config.onDiagnosticRequest(msg, client, (payload) => {
|
|
this.sendToClient(client.id, { type: 'diag-response', reqId: msg.reqId, ...payload });
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
|
|
msg.clientId = client.id;
|
|
msg.role = client.role;
|
|
this._config.onSignalingToCapture(msg);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
clearTimeout(authTimeout);
|
|
const client = this._clients.get(ws);
|
|
const wasAuthenticated = client && client.authenticated;
|
|
this._clients.delete(ws);
|
|
|
|
if (wasAuthenticated && !this._config.diagnosticMode) {
|
|
this._config.onSignalingToCapture({
|
|
type: 'client-disconnected',
|
|
clientId: client.id
|
|
});
|
|
|
|
if (this.getClientCount() === 0) {
|
|
this._config.onDestroyCaptureWindow();
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('error', () => {
|
|
clearTimeout(authTimeout);
|
|
const client = this._clients.get(ws);
|
|
const wasAuthenticated = client && client.authenticated;
|
|
this._clients.delete(ws);
|
|
|
|
if (wasAuthenticated && !this._config.diagnosticMode) {
|
|
this._config.onSignalingToCapture({
|
|
type: 'client-disconnected',
|
|
clientId: client.id
|
|
});
|
|
if (this.getClientCount() === 0) {
|
|
this._config.onDestroyCaptureWindow();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
getLastAccess() {
|
|
return this._lastAccess;
|
|
}
|
|
|
|
sendToClient(clientId, data) {
|
|
for (const [ws, client] of this._clients) {
|
|
if (client.id === clientId && client.authenticated) {
|
|
if (ws.readyState === 1) {
|
|
try { ws.send(JSON.stringify(data)); } catch {}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
broadcast(data) {
|
|
const msg = JSON.stringify(data);
|
|
for (const [ws, client] of this._clients) {
|
|
if (client.authenticated && ws.readyState === 1) {
|
|
ws.send(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
_isBlocked(ip) {
|
|
const entry = this._failedAttempts.get(ip);
|
|
if (!entry) return false;
|
|
if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true;
|
|
if (entry.blockedUntil && Date.now() >= entry.blockedUntil) {
|
|
this._failedAttempts.delete(ip);
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_recordFailedAttempt(ip) {
|
|
const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null };
|
|
entry.count++;
|
|
if (entry.count >= 5) {
|
|
entry.blockedUntil = Date.now() + 60000;
|
|
}
|
|
this._failedAttempts.set(ip, entry);
|
|
}
|
|
}
|
|
|
|
module.exports = RemoteServer;
|