Files
BetterDesk/web-nodejs/middleware/wsOrigin.js
T
Knienartowicz 8da6f8c9fd security: fix 3 critical + 3 high findings from production audit
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.
2026-05-26 13:35:12 +02:00

89 lines
3.4 KiB
JavaScript

/**
* BetterDesk Console — WebSocket Origin validation
*
* Mitigates Cross-Site WebSocket Hijacking (CSWSH).
*
* Browsers do not enforce Same-Origin Policy on WebSocket upgrades — without
* explicit Origin validation, a malicious site can open a ws:// connection
* to the panel using the victim's session cookie.
*
* Allowed origins:
* 1. Always accept same-host upgrades (Origin scheme://host[:port] === request Host)
* 2. Optional whitelist via WS_ALLOWED_ORIGINS env (comma-separated, exact match)
* 3. Optional `BD_ALLOW_MISSING_ORIGIN=true` to allow non-browser clients
* that omit Origin (default: only allowed when there is no session cookie
* attached to the request — token-based clients should send Bearer).
*
* Returns true if the upgrade should proceed, false otherwise.
*/
'use strict';
const ALLOWED_FROM_ENV = (process.env.WS_ALLOWED_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Allow missing Origin only for clearly non-browser clients. Browsers always
// send Origin on WebSocket upgrades, so absent Origin + session cookie is
// suspicious. Opt-in only for niche reverse-proxy setups.
const ALLOW_MISSING_ORIGIN = process.env.BD_ALLOW_MISSING_ORIGIN === 'true';
function sameHostOrigin(request) {
const host = request.headers && request.headers.host;
if (!host) return null;
// Cannot know if the panel was served via HTTPS from inside upgrade handler,
// so accept both schemes for the request's own host. This is safe because
// an attacker cannot make the browser send a forged Host header.
return [`http://${host}`, `https://${host}`];
}
/**
* Validate WebSocket upgrade Origin.
* @param {http.IncomingMessage} request
* @returns {{ok: true} | {ok: false, reason: string}}
*/
function validateOrigin(request) {
const origin = (request.headers && request.headers.origin) || '';
const cookies = (request.headers && request.headers.cookie) || '';
const sameHost = sameHostOrigin(request);
if (!origin) {
// No Origin: browsers always send it. Allow only when there is no
// session cookie (token-based client) OR when explicitly opted-in.
const hasSessionCookie = /bd\.sid=|betterdesk\.sid=/.test(cookies);
if (!hasSessionCookie || ALLOW_MISSING_ORIGIN) {
return { ok: true };
}
return { ok: false, reason: 'missing Origin with session cookie present' };
}
if (sameHost && sameHost.includes(origin)) {
return { ok: true };
}
if (ALLOWED_FROM_ENV.includes(origin)) {
return { ok: true };
}
return { ok: false, reason: `Origin not in allow-list: ${origin}` };
}
/**
* Helper that rejects a WebSocket upgrade if the Origin is not allowed.
* Writes HTTP 403 and destroys the socket.
* @returns {boolean} true if accepted (caller should continue), false if rejected.
*/
function enforceOrigin(request, socket, label) {
const result = validateOrigin(request);
if (result.ok) return true;
const ip = (request.socket && request.socket.remoteAddress) || '?';
// eslint-disable-next-line no-console
console.warn(`[WS-ORIGIN] Rejected upgrade (${label || 'unknown'}) from ${ip}: ${result.reason}`);
try {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
} catch (_) { /* socket already closed */ }
return false;
}
module.exports = { validateOrigin, enforceOrigin, ALLOWED_FROM_ENV };