From 4b7011131d958a6fb1d3b56b15af9487cd42f82f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:59:30 +0200 Subject: [PATCH] Reject every non-loopback runtime listener --- lib/remote-server.js | 7 +++++- main.js | 38 +++++++----------------------- tests/diagnostics-protocol.test.js | 28 ++++------------------ 3 files changed, 20 insertions(+), 53 deletions(-) diff --git a/lib/remote-server.js b/lib/remote-server.js index 86c4008..f52bb63 100644 --- a/lib/remote-server.js +++ b/lib/remote-server.js @@ -21,7 +21,12 @@ class RemoteServer { return new Promise((resolve, reject) => { this._config = opts; - const wssOpts = { port: opts.port, host: opts.host || '127.0.0.1', maxPayload: 256 * 1024 }; + const host = opts.host || '127.0.0.1'; + if (host !== '127.0.0.1' && host !== '::1') { + reject(new Error('Remote server requires a loopback host')); + return; + } + const wssOpts = { port: opts.port, host, maxPayload: 256 * 1024 }; this._wss = new WebSocketServer(wssOpts, () => { resolve(); }); diff --git a/main.js b/main.js index c868313..fa80246 100644 --- a/main.js +++ b/main.js @@ -3176,34 +3176,16 @@ function _buildDiagnosticHandler() { }; } -function _getSuggestedRemoteHosts() { - const os = require('os'); - const hosts = []; - try { - for (const entry of Object.values(os.networkInterfaces())) { - for (const net of (entry || [])) { - if (net && net.family === 'IPv4' && !net.internal && net.address) hosts.push(net.address); - } - } - } catch {} - return [...new Set(hosts)]; -} - function _diagAllowlist(diag) { return Array.isArray(diag && diag.allowlist) ? diag.allowlist.map((x) => String(x).trim()).filter(Boolean) : []; } -function _diagBindHost(diag) { - const mode = (diag && diag.bindMode) || 'local'; - if (mode === 'network' && _diagAllowlist(diag).length > 0) return '0.0.0.0'; +function _diagBindHost() { return '127.0.0.1'; } -function _diagPublicHost(diag) { - const explicit = String((diag && diag.publicHost) || '').trim(); - if (explicit) return explicit; - if (_diagBindHost(diag) === '127.0.0.1') return '127.0.0.1'; - return _getSuggestedRemoteHosts()[0] || '127.0.0.1'; +function _diagPublicHost() { + return '127.0.0.1'; } function buildDiagnosticCode(diag, fp) { @@ -3255,11 +3237,11 @@ ipcMain.handle('diagnostics:get-settings', () => { return { enabled: !!diag.enabled, port: diag.port || 9110, - bindMode: diag.bindMode === 'network' ? 'network' : 'local', + bindMode: 'local', bindAddress: _diagBindHost(diag), - publicHost: diag.publicHost || '', + publicHost: '127.0.0.1', allowlist: _diagAllowlist(diag), - suggestedHosts: _getSuggestedRemoteHosts(), + suggestedHosts: [], label: diag.label || require('os').hostname(), codeIssuedAt: diag.codeIssuedAt || 0, code: diag.token ? buildDiagnosticCode(diag) : '' @@ -3274,11 +3256,9 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => { ...cur, enabled: !!(incoming && incoming.enabled), port: (incoming && Number(incoming.port)) || cur.port || 9110, - bindMode: (incoming && incoming.bindMode === 'network') ? 'network' : 'local', - publicHost: (incoming && incoming.publicHost !== null && incoming.publicHost !== undefined) ? String(incoming.publicHost).trim() : (cur.publicHost || ''), - allowlist: (incoming && Array.isArray(incoming.allowlist)) - ? incoming.allowlist.map((x) => String(x).trim()).filter(Boolean) - : _diagAllowlist(cur), + bindMode: 'local', + publicHost: '127.0.0.1', + allowlist: [], label: (incoming && incoming.label !== null && incoming.label !== undefined) ? String(incoming.label) : cur.label }; next.bindAddress = _diagBindHost(next); diff --git a/tests/diagnostics-protocol.test.js b/tests/diagnostics-protocol.test.js index 6dc7af6..3fe0a8f 100644 --- a/tests/diagnostics-protocol.test.js +++ b/tests/diagnostics-protocol.test.js @@ -1,20 +1,10 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const os = require('os'); const WebSocket = require('ws'); const RemoteServer = require('../lib/remote-server'); const TOKEN = 'a'.repeat(64); -function firstLanIpv4() { - for (const entry of Object.values(os.networkInterfaces())) { - for (const net of (entry || [])) { - if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address; - } - } - return null; -} - function startAgent(onDiagnosticRequest, extra) { const srv = new RemoteServer(); return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) }) @@ -88,19 +78,11 @@ test('a loopback diagnostic client connects even with a non-matching allowlist ( ws.close(); agent.stop(); }); -test('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => { - const lan = firstLanIpv4(); - if (!lan) { t.skip('no non-internal IPv4 interface available'); return; } - const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] }); - const port = agent.getPort(); - const ws = new WebSocket(`ws://${lan}:${port}`); - try { - await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); - ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' })); - const ok = await once(ws, 'auth-ok'); - assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind'); - } finally { - ws.close(); agent.stop(); +test('diagnostic server rejects wildcard and non-loopback binds before opening a listener', async () => { + for (const host of ['0.0.0.0', '::', '192.0.2.10']) { + const server = new RemoteServer(); + await assert.rejects(server.start({ port: 0, host, token: TOKEN, diagnosticMode: true }), /requires a loopback host/); + assert.equal(server._wss, null); } });