Reject every non-loopback runtime listener
This commit is contained in:
@@ -21,7 +21,12 @@ class RemoteServer {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this._config = opts;
|
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, () => {
|
this._wss = new WebSocketServer(wssOpts, () => {
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
function _diagAllowlist(diag) {
|
||||||
return Array.isArray(diag && diag.allowlist) ? diag.allowlist.map((x) => String(x).trim()).filter(Boolean) : [];
|
return Array.isArray(diag && diag.allowlist) ? diag.allowlist.map((x) => String(x).trim()).filter(Boolean) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _diagBindHost(diag) {
|
function _diagBindHost() {
|
||||||
const mode = (diag && diag.bindMode) || 'local';
|
|
||||||
if (mode === 'network' && _diagAllowlist(diag).length > 0) return '0.0.0.0';
|
|
||||||
return '127.0.0.1';
|
return '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _diagPublicHost(diag) {
|
function _diagPublicHost() {
|
||||||
const explicit = String((diag && diag.publicHost) || '').trim();
|
return '127.0.0.1';
|
||||||
if (explicit) return explicit;
|
|
||||||
if (_diagBindHost(diag) === '127.0.0.1') return '127.0.0.1';
|
|
||||||
return _getSuggestedRemoteHosts()[0] || '127.0.0.1';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiagnosticCode(diag, fp) {
|
function buildDiagnosticCode(diag, fp) {
|
||||||
@@ -3255,11 +3237,11 @@ ipcMain.handle('diagnostics:get-settings', () => {
|
|||||||
return {
|
return {
|
||||||
enabled: !!diag.enabled,
|
enabled: !!diag.enabled,
|
||||||
port: diag.port || 9110,
|
port: diag.port || 9110,
|
||||||
bindMode: diag.bindMode === 'network' ? 'network' : 'local',
|
bindMode: 'local',
|
||||||
bindAddress: _diagBindHost(diag),
|
bindAddress: _diagBindHost(diag),
|
||||||
publicHost: diag.publicHost || '',
|
publicHost: '127.0.0.1',
|
||||||
allowlist: _diagAllowlist(diag),
|
allowlist: _diagAllowlist(diag),
|
||||||
suggestedHosts: _getSuggestedRemoteHosts(),
|
suggestedHosts: [],
|
||||||
label: diag.label || require('os').hostname(),
|
label: diag.label || require('os').hostname(),
|
||||||
codeIssuedAt: diag.codeIssuedAt || 0,
|
codeIssuedAt: diag.codeIssuedAt || 0,
|
||||||
code: diag.token ? buildDiagnosticCode(diag) : ''
|
code: diag.token ? buildDiagnosticCode(diag) : ''
|
||||||
@@ -3274,11 +3256,9 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
|
|||||||
...cur,
|
...cur,
|
||||||
enabled: !!(incoming && incoming.enabled),
|
enabled: !!(incoming && incoming.enabled),
|
||||||
port: (incoming && Number(incoming.port)) || cur.port || 9110,
|
port: (incoming && Number(incoming.port)) || cur.port || 9110,
|
||||||
bindMode: (incoming && incoming.bindMode === 'network') ? 'network' : 'local',
|
bindMode: 'local',
|
||||||
publicHost: (incoming && incoming.publicHost !== null && incoming.publicHost !== undefined) ? String(incoming.publicHost).trim() : (cur.publicHost || ''),
|
publicHost: '127.0.0.1',
|
||||||
allowlist: (incoming && Array.isArray(incoming.allowlist))
|
allowlist: [],
|
||||||
? incoming.allowlist.map((x) => String(x).trim()).filter(Boolean)
|
|
||||||
: _diagAllowlist(cur),
|
|
||||||
label: (incoming && incoming.label !== null && incoming.label !== undefined) ? String(incoming.label) : cur.label
|
label: (incoming && incoming.label !== null && incoming.label !== undefined) ? String(incoming.label) : cur.label
|
||||||
};
|
};
|
||||||
next.bindAddress = _diagBindHost(next);
|
next.bindAddress = _diagBindHost(next);
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
const os = require('os');
|
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const RemoteServer = require('../lib/remote-server');
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
|
||||||
const TOKEN = 'a'.repeat(64);
|
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) {
|
function startAgent(onDiagnosticRequest, extra) {
|
||||||
const srv = new RemoteServer();
|
const srv = new RemoteServer();
|
||||||
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
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();
|
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) => {
|
test('diagnostic server rejects wildcard and non-loopback binds before opening a listener', async () => {
|
||||||
const lan = firstLanIpv4();
|
for (const host of ['0.0.0.0', '::', '192.0.2.10']) {
|
||||||
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
|
const server = new RemoteServer();
|
||||||
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
|
await assert.rejects(server.start({ port: 0, host, token: TOKEN, diagnosticMode: true }), /requires a loopback host/);
|
||||||
const port = agent.getPort();
|
assert.equal(server._wss, null);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user