'use strict'; // RFB protocol handling for the hub. // // The hub is a man-in-the-middle by design: it completes the RFB handshake with // the real VNC server itself (including VNC Authentication using a password the // browser never receives), and separately presents a "no authentication needed" // handshake to the browser. Once both sides are past ServerInit the two streams // are spliced together. // // That MITM position is also what makes view-only enforceable: the client->server // direction is parsed and input-bearing messages are dropped, so a viewer cannot // send keystrokes no matter what its browser does. const { vncAuthResponse } = require('./des'); const SEC_NONE = 1; const SEC_VNC_AUTH = 2; /** * Reads exact byte counts off a Readable without putting it into flowing mode, * so anything we do not consume stays in the stream's internal buffer and is * picked up by the later pipe(). */ class ByteReader { constructor(stream, timeoutMs = 20_000) { this.stream = stream; this.timeoutMs = timeoutMs; } read(n) { return new Promise((resolve, reject) => { const attempt = () => { const buf = this.stream.read(n); if (buf) { cleanup(); resolve(buf); } }; const onEnd = () => { cleanup(); reject(new Error('connection closed during RFB handshake')); }; const onError = (err) => { cleanup(); reject(err); }; const timer = setTimeout(() => { cleanup(); reject(new Error('timed out during RFB handshake')); }, this.timeoutMs); const cleanup = () => { clearTimeout(timer); this.stream.off('readable', attempt); this.stream.off('end', onEnd); this.stream.off('close', onEnd); this.stream.off('error', onError); }; this.stream.on('readable', attempt); this.stream.on('end', onEnd); this.stream.on('close', onEnd); this.stream.on('error', onError); attempt(); }); } async readU8() { return (await this.read(1))[0]; } async readU32() { return (await this.read(4)).readUInt32BE(0); } // RFB failure reasons are a u32 length followed by that many bytes of text. async readReason() { const len = await this.readU32(); if (!len) return ''; return (await this.read(Math.min(len, 4096))).toString('utf8'); } } function parseVersion(buf) { const text = buf.toString('ascii'); const m = /^RFB (\d{3})\.(\d{3})\n$/.exec(text); if (!m) throw new Error(`not a VNC server (got ${JSON.stringify(text)})`); return { major: Number(m[1]), minor: Number(m[2]) }; } /** * Act as a VNC *client* toward the real server: version negotiation, security * negotiation, VNC Authentication if required. Returns once the server is ready * for ClientInit, which the browser will supply. */ async function handshakeWithServer(stream, password, timeoutMs = 20_000) { const r = new ByteReader(stream, timeoutMs); const { major, minor: rawMinor } = parseVersion(await r.read(12)); // Apple advertises 003.889; anything above 3.8 is negotiated down to 3.8. const minor = rawMinor > 8 ? 8 : rawMinor; const negotiated = minor >= 8 ? 8 : minor >= 7 ? 7 : 3; stream.write(Buffer.from(`RFB 003.00${negotiated}\n`, 'ascii')); let secType; if (negotiated >= 7) { const count = await r.readU8(); if (count === 0) throw new Error(`server refused connection: ${await r.readReason()}`); const types = Array.from(await r.read(count)); if (password && types.includes(SEC_VNC_AUTH)) secType = SEC_VNC_AUTH; else if (types.includes(SEC_NONE)) secType = SEC_NONE; else if (types.includes(SEC_VNC_AUTH)) { throw new Error('VNC server requires a password but none is stored for this client'); } else { throw new Error(`no supported VNC security type (server offered ${types.join(', ')})`); } stream.write(Buffer.from([secType])); } else { secType = await r.readU32(); if (secType === 0) throw new Error(`server refused connection: ${await r.readReason()}`); if (secType === SEC_VNC_AUTH && !password) { throw new Error('VNC server requires a password but none is stored for this client'); } } if (secType === SEC_VNC_AUTH) { const challenge = await r.read(16); stream.write(vncAuthResponse(challenge, password)); } else if (secType !== SEC_NONE) { throw new Error(`unsupported VNC security type ${secType}`); } // 3.8 always sends SecurityResult; earlier versions only send it for real auth. if (negotiated >= 8 || secType !== SEC_NONE) { const result = await r.readU32(); if (result !== 0) { const reason = negotiated >= 8 ? await r.readReason().catch(() => '') : ''; throw new Error(reason || 'VNC authentication failed (wrong password?)'); } } return { version: `${major}.${rawMinor}`, securityType: secType }; } /** * Act as a VNC *server* toward the browser, offering "None" security. By the * time this runs the hub has already authenticated upstream, so the browser is * handed an already-authorised stream and never sees the real password. */ async function handshakeWithBrowser(stream, timeoutMs = 20_000) { const r = new ByteReader(stream, timeoutMs); stream.write(Buffer.from('RFB 003.008\n', 'ascii')); const { minor } = parseVersion(await r.read(12)); if (minor >= 7) { stream.write(Buffer.from([1, SEC_NONE])); const chosen = await r.readU8(); if (chosen !== SEC_NONE) { const reason = Buffer.from('unsupported security type', 'utf8'); const buf = Buffer.alloc(8 + reason.length); buf.writeUInt32BE(1, 0); buf.writeUInt32BE(reason.length, 4); reason.copy(buf, 8); stream.write(buf); throw new Error('browser chose an unsupported security type'); } // SecurityResult: OK const ok = Buffer.alloc(4); ok.writeUInt32BE(0, 0); stream.write(ok); } else { // RFB 3.3: the server dictates the security type and sends no SecurityResult. const buf = Buffer.alloc(4); buf.writeUInt32BE(SEC_NONE, 0); stream.write(buf); } } /* ------------------------------------------------------------------------ */ /* View-only enforcement */ /* ------------------------------------------------------------------------ */ // Client-to-server messages that cannot change anything on the remote machine. // Everything else is dropped for viewers — notably KeyEvent, PointerEvent, // ClientCutText (paste), SetDesktopSize and xvp (which can power off a host). const PASSIVE_MESSAGES = new Set([ 0, // SetPixelFormat 2, // SetEncodings 3, // FramebufferUpdateRequest 150, // EnableContinuousUpdates 248, // ClientFence ]); /** * Length of the client->server message starting at offset 0 of `buf`. * Returns 0 when more bytes are needed, -1 when the type is unknown (which means * we can no longer track message boundaries and must drop the connection). */ function clientMessageLength(buf) { const type = buf[0]; switch (type) { case 0: return 20; // SetPixelFormat case 2: // SetEncodings if (buf.length < 4) return 0; return 4 + 4 * buf.readUInt16BE(2); case 3: return 10; // FramebufferUpdateRequest case 4: return 8; // KeyEvent case 5: return 6; // PointerEvent case 6: // ClientCutText if (buf.length < 8) return 0; // A negative length marks the extended clipboard extension. return 8 + Math.abs(buf.readInt32BE(4)); case 150: return 10; // EnableContinuousUpdates case 248: // ClientFence if (buf.length < 9) return 0; return 9 + buf[8]; case 250: return 4; // xvp (shutdown/reboot/reset) case 251: // SetDesktopSize if (buf.length < 8) return 0; return 8 + 16 * buf[6]; case 255: // QEMU client message if (buf.length < 2) return 0; if (buf[1] === 0) return 12; // QEMU Extended Key Event return -1; default: return -1; } } /** * Incremental filter for the browser->server direction of a view-only session. * Feed it chunks; it returns only the bytes that are safe to forward. */ class ViewOnlyFilter { constructor() { this.pending = Buffer.alloc(0); this.blocked = 0; } push(chunk) { this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk; const keep = []; while (this.pending.length > 0) { const len = clientMessageLength(this.pending); if (len === -1) { throw new Error(`unparseable client message type ${this.pending[0]} in view-only session`); } if (len === 0 || this.pending.length < len) break; const msg = this.pending.subarray(0, len); if (PASSIVE_MESSAGES.has(msg[0])) keep.push(Buffer.from(msg)); else this.blocked++; this.pending = this.pending.subarray(len); } if (!keep.length) return null; return keep.length === 1 ? keep[0] : Buffer.concat(keep); } } module.exports = { ByteReader, handshakeWithServer, handshakeWithBrowser, ViewOnlyFilter, clientMessageLength, SEC_NONE, SEC_VNC_AUTH, };