mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
8da6f8c9fd
Critical: - C-01: WebSocket Origin validation (CSWSH protection) via new middleware/wsOrigin.js, applied to wsRelay, chatRelay, remoteRelay, bdRelay, cdapTerminalProxy, cdapMediaProxy - C-02: Remove Tauri Origin-based CSRF bypass in server.js; only /api/bd/* skipped - C-03: Update vulnerable deps (express 4.21.2, multer 2.0.0, protobufjs 7.4.0, helmet 7.2.0, axios 1.9.0, cookie-parser 1.4.7, express-session 1.18.1) High: - H-01: Disable auto-create-local-user on login by default; opt-in via BETTERDESK_AUTH_AUTOCREATE=true (does not affect ensureDefaultAdmin fresh-install bootstrap) - H-02: Bearer-only auth for /api/bd/*; session cookie fallback removed; tokens redacted in logs - H-05: Replace custom PBKDF2 with golang.org/x/crypto/pbkdf2; 600k iterations; new format pbkdf2-sha256\\\ with backward-compat for legacy salt:hash Backward compatibility: legacy password hashes still verify; CSRF tokens still obtainable via csrfTokenProvider; fresh-install admin bootstrap (.admin_credentials flow) unchanged. Audit report: docs/security/AUDIT_PRODUCTION_2026-04-10.md This commit was made possible thanks to Insolve.
543 lines
17 KiB
JavaScript
543 lines
17 KiB
JavaScript
/**
|
|
* BetterDesk Console — Native WebSocket Relay
|
|
*
|
|
* Provides a direct relay channel between two BetterDesk desktop clients.
|
|
* Unlike the RustDesk WS proxy (wsRelay.js) which bridges browser→hbbr TCP,
|
|
* this relay pairs two desktop clients directly and passes E2E-encrypted
|
|
* binary frames between them. The server NEVER decrypts the payload.
|
|
*
|
|
* Protocol:
|
|
* 1. Client A → POST /api/bd/connect/:targetId
|
|
* → returns { sessionId, token }
|
|
* 2. Both clients → WS /ws/bd-relay?session=<id>&token=<token>&role=initiator|target
|
|
* 3. Server pairs them once both sides are connected
|
|
* 4. Binary frames are forwarded 1:1 between the pair
|
|
* 5. Either side closes → session torn down
|
|
*
|
|
* Security:
|
|
* - Tokens are single-use, short-lived (60 s default)
|
|
* - Rate-limited per IP (MAX_RELAY_PER_IP)
|
|
* - Binary-only after handshake (text frames rejected)
|
|
* - Maximum frame size enforced (MAX_FRAME_BYTES)
|
|
* - Session auto-cleanup every 30 s
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const WebSocket = require('ws');
|
|
const crypto = require('crypto');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_RELAY_PER_IP = 10; // concurrent relay WS per IP
|
|
const MAX_FRAME_BYTES = 2 * 1024 * 1024; // 2 MB per frame
|
|
const SESSION_TTL_MS = 60 * 1000; // token valid for 60 s after creation
|
|
const IDLE_TIMEOUT_MS = 120 * 1000; // close if no data for 2 min
|
|
const CLEANUP_INTERVAL = 30 * 1000; // cleanup tick
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// sessionId → { initiatorWs, targetWs, initiatorToken, targetToken, createdAt, status }
|
|
const activeSessions = new Map();
|
|
|
|
// deviceId → WebSocket (for online device tracking / push notifications)
|
|
const onlineDevices = new Map();
|
|
|
|
// IP → count
|
|
const connectionsPerIp = new Map();
|
|
|
|
// requestId → { resolve, reject, timeout } for request/response pattern over signal WS
|
|
const pendingRequests = new Map();
|
|
|
|
const REQUEST_TIMEOUT_MS = 15 * 1000; // 15s default
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function generateToken() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
function clientIp(req) {
|
|
return req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
|
|| req.socket?.remoteAddress
|
|
|| 'unknown';
|
|
}
|
|
|
|
function incIpCount(ip) {
|
|
connectionsPerIp.set(ip, (connectionsPerIp.get(ip) || 0) + 1);
|
|
}
|
|
|
|
function decIpCount(ip) {
|
|
const c = (connectionsPerIp.get(ip) || 1) - 1;
|
|
if (c <= 0) connectionsPerIp.delete(ip);
|
|
else connectionsPerIp.set(ip, c);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session management (called by bd-api routes)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a new relay session. Returns tokens for both participants.
|
|
* @param {string} initiatorId - device ID of the caller
|
|
* @param {string} targetId - device ID of the target
|
|
* @returns {{ sessionId: string, initiatorToken: string, targetToken: string }}
|
|
*/
|
|
function createRelaySession(initiatorId, targetId) {
|
|
const sessionId = crypto.randomUUID();
|
|
const initiatorToken = generateToken();
|
|
const targetToken = generateToken();
|
|
|
|
activeSessions.set(sessionId, {
|
|
initiatorId,
|
|
targetId,
|
|
initiatorWs: null,
|
|
targetWs: null,
|
|
initiatorToken,
|
|
targetToken,
|
|
createdAt: Date.now(),
|
|
status: 'pending', // pending → active → closed
|
|
});
|
|
|
|
return { sessionId, initiatorToken, targetToken };
|
|
}
|
|
|
|
/**
|
|
* Get session metadata (without tokens) for API responses.
|
|
*/
|
|
function getRelaySession(sessionId) {
|
|
const s = activeSessions.get(sessionId);
|
|
if (!s) return null;
|
|
return {
|
|
sessionId,
|
|
initiatorId: s.initiatorId,
|
|
targetId: s.targetId,
|
|
status: s.status,
|
|
createdAt: s.createdAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Notify a target device that someone wants to connect.
|
|
* Sends a JSON control message over the device's signaling WS.
|
|
*/
|
|
function notifyTarget(targetId, payload) {
|
|
const ws = onlineDevices.get(targetId);
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(payload));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if a device is currently online (has an active signaling WS).
|
|
*/
|
|
function isDeviceOnline(deviceId) {
|
|
const ws = onlineDevices.get(deviceId);
|
|
return ws && ws.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
/**
|
|
* Get list of currently online device IDs.
|
|
*/
|
|
function getOnlineDeviceIds() {
|
|
const ids = [];
|
|
for (const [id, ws] of onlineDevices) {
|
|
if (ws.readyState === WebSocket.OPEN) ids.push(id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/**
|
|
* Send a request to an online agent and await its response over the signal WS.
|
|
* The agent receives `{ type, request_id, payload }` and must reply with
|
|
* `{ type: 'command_response', request_id, ok, data?, error? }`.
|
|
*
|
|
* @param {string} deviceId
|
|
* @param {string} type e.g. 'services.list', 'files.browse'
|
|
* @param {object} payload optional type-specific payload
|
|
* @param {number} [timeoutMs=15000]
|
|
* @returns {Promise<any>} resolves with agent's `data`, rejects on error/timeout
|
|
*/
|
|
function requestFromDevice(deviceId, type, payload = null, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = onlineDevices.get(deviceId);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
return reject(new Error('agent_offline'));
|
|
}
|
|
|
|
const requestId = crypto.randomUUID();
|
|
const timeout = setTimeout(() => {
|
|
pendingRequests.delete(requestId);
|
|
reject(new Error('agent_timeout'));
|
|
}, Math.max(1000, Math.min(60000, timeoutMs)));
|
|
|
|
pendingRequests.set(requestId, { resolve, reject, timeout });
|
|
|
|
try {
|
|
ws.send(JSON.stringify({ type, request_id: requestId, payload }));
|
|
} catch (err) {
|
|
pendingRequests.delete(requestId);
|
|
clearTimeout(timeout);
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebSocket server initialisation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Initialize the BetterDesk relay WebSocket endpoints:
|
|
* /ws/bd-relay — data relay between paired clients
|
|
* /ws/bd-signal — signaling / presence channel per device
|
|
*
|
|
* @param {http.Server} server
|
|
*/
|
|
function initBdRelay(server) {
|
|
const relayWss = new WebSocket.Server({ noServer: true, maxPayload: MAX_FRAME_BYTES });
|
|
const signalWss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024 });
|
|
const { enforceOrigin } = require('../middleware/wsOrigin');
|
|
|
|
// Attach to server upgrade — only handle /ws/bd-relay and /ws/bd-signal,
|
|
// let other handlers (remoteRelay, chatRelay, cdap) handle their paths.
|
|
server.on('upgrade', (request, socket, head) => {
|
|
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
const pathname = url.pathname;
|
|
|
|
if (pathname === '/ws/bd-relay') {
|
|
if (!enforceOrigin(request, socket, `bd-relay ${pathname}`)) return;
|
|
relayWss.handleUpgrade(request, socket, head, (ws) => {
|
|
relayWss.emit('connection', ws, request);
|
|
});
|
|
} else if (pathname === '/ws/bd-signal') {
|
|
if (!enforceOrigin(request, socket, `bd-signal ${pathname}`)) return;
|
|
signalWss.handleUpgrade(request, socket, head, (ws) => {
|
|
signalWss.emit('connection', ws, request);
|
|
});
|
|
}
|
|
// Other paths: do nothing — let other upgrade handlers deal with them
|
|
});
|
|
|
|
// ---- Relay connections ----
|
|
|
|
relayWss.on('connection', (ws, req) => {
|
|
handleRelayConnection(ws, req);
|
|
});
|
|
|
|
// ---- Signal connections ----
|
|
|
|
signalWss.on('connection', (ws, req) => {
|
|
handleSignalConnection(ws, req);
|
|
});
|
|
|
|
// Cleanup interval
|
|
const cleanupTimer = setInterval(cleanupSessions, CLEANUP_INTERVAL);
|
|
cleanupTimer.unref();
|
|
|
|
console.log(' BetterDesk relay: /ws/bd-relay (data relay)');
|
|
console.log(' BetterDesk relay: /ws/bd-signal (signaling)');
|
|
|
|
return { relayWss, signalWss };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Relay connection handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handleRelayConnection(ws, req) {
|
|
const ip = clientIp(req);
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const sessionId = url.searchParams.get('session');
|
|
const token = url.searchParams.get('token');
|
|
const role = url.searchParams.get('role'); // 'initiator' | 'target'
|
|
|
|
// Validate parameters
|
|
if (!sessionId || !token || !role || !['initiator', 'target'].includes(role)) {
|
|
ws.close(4400, 'Missing or invalid parameters');
|
|
return;
|
|
}
|
|
|
|
// IP rate limit
|
|
if ((connectionsPerIp.get(ip) || 0) >= MAX_RELAY_PER_IP) {
|
|
ws.close(4429, 'Too many connections');
|
|
return;
|
|
}
|
|
|
|
const session = activeSessions.get(sessionId);
|
|
if (!session) {
|
|
ws.close(4404, 'Session not found');
|
|
return;
|
|
}
|
|
|
|
// Validate token
|
|
const expectedToken = role === 'initiator' ? session.initiatorToken : session.targetToken;
|
|
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
|
|
ws.close(4403, 'Invalid token');
|
|
return;
|
|
}
|
|
|
|
// Check session age
|
|
if (Date.now() - session.createdAt > SESSION_TTL_MS && session.status === 'pending') {
|
|
activeSessions.delete(sessionId);
|
|
ws.close(4408, 'Session expired');
|
|
return;
|
|
}
|
|
|
|
// Attach
|
|
incIpCount(ip);
|
|
const slotKey = role === 'initiator' ? 'initiatorWs' : 'targetWs';
|
|
|
|
if (session[slotKey]) {
|
|
ws.close(4409, 'Slot already taken');
|
|
decIpCount(ip);
|
|
return;
|
|
}
|
|
|
|
session[slotKey] = ws;
|
|
|
|
// If both sides connected → activate
|
|
if (session.initiatorWs && session.targetWs) {
|
|
activateRelay(sessionId, session);
|
|
}
|
|
|
|
// Idle timeout for waiting side
|
|
let idleTimer = setTimeout(() => {
|
|
if (session.status === 'pending') {
|
|
teardownSession(sessionId, 4408, 'Timeout waiting for peer');
|
|
}
|
|
}, SESSION_TTL_MS);
|
|
|
|
ws.on('close', () => {
|
|
clearTimeout(idleTimer);
|
|
decIpCount(ip);
|
|
teardownSession(sessionId, 1000, 'Peer disconnected');
|
|
});
|
|
|
|
ws.on('error', () => {
|
|
clearTimeout(idleTimer);
|
|
decIpCount(ip);
|
|
teardownSession(sessionId, 1011, 'WebSocket error');
|
|
});
|
|
}
|
|
|
|
function activateRelay(sessionId, session) {
|
|
session.status = 'active';
|
|
|
|
const wsA = session.initiatorWs;
|
|
const wsB = session.targetWs;
|
|
|
|
// Send paired notification (JSON text frame)
|
|
const pairMsg = JSON.stringify({ type: 'paired', session: sessionId });
|
|
if (wsA.readyState === WebSocket.OPEN) wsA.send(pairMsg);
|
|
if (wsB.readyState === WebSocket.OPEN) wsB.send(pairMsg);
|
|
|
|
// Data relay (binary only after pairing)
|
|
let idleTimer = null;
|
|
const resetIdle = () => {
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
idleTimer = setTimeout(() => {
|
|
teardownSession(sessionId, 4408, 'Idle timeout');
|
|
}, IDLE_TIMEOUT_MS);
|
|
};
|
|
|
|
resetIdle();
|
|
|
|
wsA.on('message', (data, isBinary) => {
|
|
resetIdle();
|
|
if (wsB.readyState === WebSocket.OPEN) {
|
|
wsB.send(data, { binary: isBinary });
|
|
}
|
|
});
|
|
|
|
wsB.on('message', (data, isBinary) => {
|
|
resetIdle();
|
|
if (wsA.readyState === WebSocket.OPEN) {
|
|
wsA.send(data, { binary: isBinary });
|
|
}
|
|
});
|
|
|
|
const origClose = () => {
|
|
clearTimeout(idleTimer);
|
|
};
|
|
wsA.on('close', origClose);
|
|
wsB.on('close', origClose);
|
|
}
|
|
|
|
function teardownSession(sessionId, code, reason) {
|
|
const session = activeSessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
session.status = 'closed';
|
|
activeSessions.delete(sessionId);
|
|
|
|
for (const key of ['initiatorWs', 'targetWs']) {
|
|
const ws = session[key];
|
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
try { ws.close(code, reason); } catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Signal connection handler (presence / push)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handleSignalConnection(ws, req) {
|
|
const ip = clientIp(req);
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const deviceId = url.searchParams.get('device_id');
|
|
const token = url.searchParams.get('token');
|
|
|
|
if (!deviceId || !token) {
|
|
ws.close(4400, 'Missing device_id or token');
|
|
return;
|
|
}
|
|
|
|
// IP rate limit
|
|
if ((connectionsPerIp.get(ip) || 0) >= MAX_RELAY_PER_IP) {
|
|
ws.close(4429, 'Too many connections');
|
|
return;
|
|
}
|
|
|
|
incIpCount(ip);
|
|
|
|
// Store reference for push notifications
|
|
// If device already has a signal WS, close the old one
|
|
const existing = onlineDevices.get(deviceId);
|
|
if (existing && existing.readyState === WebSocket.OPEN) {
|
|
existing.close(4409, 'Replaced by new connection');
|
|
}
|
|
onlineDevices.set(deviceId, ws);
|
|
|
|
// Heartbeat
|
|
let heartbeatTimer = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.ping();
|
|
}
|
|
}, 30000);
|
|
|
|
ws.on('message', (data) => {
|
|
// Signal messages are JSON
|
|
try {
|
|
const msg = JSON.parse(data.toString());
|
|
handleSignalMessage(deviceId, ws, msg);
|
|
} catch (_) {
|
|
// Ignore malformed messages
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
clearInterval(heartbeatTimer);
|
|
decIpCount(ip);
|
|
if (onlineDevices.get(deviceId) === ws) {
|
|
onlineDevices.delete(deviceId);
|
|
}
|
|
});
|
|
|
|
ws.on('error', () => {
|
|
clearInterval(heartbeatTimer);
|
|
decIpCount(ip);
|
|
if (onlineDevices.get(deviceId) === ws) {
|
|
onlineDevices.delete(deviceId);
|
|
}
|
|
});
|
|
|
|
// Acknowledge connection
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'welcome', device_id: deviceId }));
|
|
}
|
|
}
|
|
|
|
function handleSignalMessage(deviceId, ws, msg) {
|
|
switch (msg.type) {
|
|
case 'heartbeat':
|
|
ws.send(JSON.stringify({ type: 'heartbeat_ack', ts: Date.now() }));
|
|
break;
|
|
|
|
case 'relay_accept': {
|
|
// Target device accepts incoming connection
|
|
const session = activeSessions.get(msg.session_id);
|
|
if (session && session.targetId === deviceId && session.status === 'pending') {
|
|
// Send target token to the target device so it can connect to the relay WS
|
|
ws.send(JSON.stringify({
|
|
type: 'relay_ready',
|
|
session_id: msg.session_id,
|
|
token: session.targetToken,
|
|
}));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'relay_reject': {
|
|
// Target device rejects incoming connection
|
|
const session = activeSessions.get(msg.session_id);
|
|
if (session && session.targetId === deviceId) {
|
|
teardownSession(msg.session_id, 4403, 'Connection rejected by target');
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'command_response': {
|
|
// Agent responding to a requestFromDevice() call.
|
|
// msg = { type, request_id, ok, data?, error? }
|
|
const pending = pendingRequests.get(msg.request_id);
|
|
if (pending) {
|
|
pendingRequests.delete(msg.request_id);
|
|
clearTimeout(pending.timeout);
|
|
if (msg.ok) pending.resolve(msg.data || null);
|
|
else pending.reject(new Error(msg.error || 'agent_error'));
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cleanup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function cleanupSessions() {
|
|
const now = Date.now();
|
|
for (const [id, session] of activeSessions) {
|
|
// Expired pending sessions
|
|
if (session.status === 'pending' && now - session.createdAt > SESSION_TTL_MS * 2) {
|
|
teardownSession(id, 4408, 'Session expired');
|
|
}
|
|
}
|
|
|
|
// Clean dead device entries
|
|
for (const [id, ws] of onlineDevices) {
|
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
onlineDevices.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exports
|
|
// ---------------------------------------------------------------------------
|
|
|
|
module.exports = {
|
|
initBdRelay,
|
|
createRelaySession,
|
|
getRelaySession,
|
|
notifyTarget,
|
|
isDeviceOnline,
|
|
getOnlineDeviceIds,
|
|
requestFromDevice,
|
|
activeSessions, // exposed for monitoring/admin API
|
|
onlineDevices, // exposed for status sync
|
|
};
|