OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay commits / a054b90

security: close two unauthenticated-access paths (C-1, R-1)
C-1 — every default relay granted anonymous swarm peers the `authenticated-user` role. Commit 9125f3c ("default services to anonymous role") set 'anonymous' in relay-node/index.js and bare-relay.js but not in config/default.js, and that is the file the CLI loads — merged last, so it won. Verified before and after: HOME=$(mktemp -d) loadConfig({}) -> "authenticated-user" (before) HOME=$(mktemp -d) loadConfig({}) -> "anonymous" (after) Effect was that protocol.js:525 handed any anonymous peer the authenticated-user role, and router/index.js:246 then opened arbitration.submit — whose pending Map is uncapped — on every box running services. No test asserted the value in either file, which is why the revert went unnoticed; there is now one, and it asserts the EFFECTIVE value so it still fails if the precedence between the two defaults changes. R-1 — dashboard-admin-operator was an unauthenticated fleet-root control plane. It binds every relay's SSH key and its endpoints restart services, run `git pull && npm install`, rewrite config.json and edit /etc/fstab; it was listening on 0.0.0.0 with zero auth and wildcard CORS. Confirmed live on the workstation as `node ... TCP *:3458 (LISTEN)`. Its own README says "do not expose port 3458", and the sibling scripts/local-fleet-dashboard.mjs already binds 127.0.0.1 correctly. - bind 127.0.0.1 by default; warn loudly if overridden off loopback - shared-secret auth on every route, ahead of the static UI and the SSE stream (which leaks the inventory and action log); timing-safe compare - drop wildcard CORS - replace exec(`... ${ip} ...`) with execFile(argv): publicIp is caller-controlled via POST /api/relay, which validated only truthiness, and the probe re-ran it every 60s — a standing remote-command-execution sink. Also validate host and name at that boundary. Verified: loopback-only bind; 401 with no/incorrect token, 200 with; `{"publicIp":"1.2.3.4; touch /tmp/PWNED"}` rejected 400 and no command ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a054b902ab88dc6b850790611c4a8e7041d5920e
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-28T13:58:28.000Z

Changed files (3) +123 -9

dashboard-admin-operator/server.cjs +89 -8
……@@ -20,13 +20,42 @@
2020 */
2121
2222const http = require('http')
23const { exec } = require('child_process')
23const crypto = require('crypto')
24const { exec, execFile } = require('child_process')
2425const fs = require('fs')
2526const path = require('path')
2627const { createController } = require('./controller.cjs')
2728
2829const INTERVAL_S = parseInt(process.env.FLEET_INTERVAL || '60', 10)
2930const PORT = parseInt(process.env.PORT || '3458', 10)
31
32// This process is a fleet-root control plane: it holds every relay's SSH key and
33// its endpoints restart services, run `git pull && npm install`, rewrite
34// config.json and edit /etc/fstab. Treat it accordingly.
35//
36// It previously bound 0.0.0.0 with no authentication, which made it remote root
37// on all 13 relays for anyone on the same network. Two independent guards now:
38// bind loopback, and require a shared secret on every request.
39const HOST = process.env.HIVERELAY_ADMIN_HOST || '127.0.0.1'
40const TOKEN = process.env.HIVERELAY_ADMIN_TOKEN || crypto.randomBytes(24).toString('hex')
41const TOKEN_WAS_GENERATED = !process.env.HIVERELAY_ADMIN_TOKEN
42
43// Timing-safe compare so the token can't be recovered byte-by-byte.
44function tokenOk (presented) {
45 if (typeof presented !== 'string' || presented.length !== TOKEN.length) return false
46 return crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(TOKEN))
47}
48
49function presentedToken (req) {
50 const auth = req.headers.authorization || ''
51 if (auth.startsWith('Bearer ')) return auth.slice(7)
52 if (req.headers['x-admin-token']) return String(req.headers['x-admin-token'])
53 try {
54 return new URL(req.url, 'http://localhost').searchParams.get('token') || ''
55 } catch (_) {
56 return ''
57 }
58}
3059const POLICY_PATH = process.env.HIVERELAY_CONTROL_POLICY
3160 || path.join(__dirname, 'control-policy.json')
3261
……@@ -74,6 +103,15 @@ function loadRelays () {
74103 return JSON.parse(fs.readFileSync(RELAYS_JSON, 'utf8')).relays
75104}
76105
106// Hostname/IP allowlist, applied before any value from the inventory reaches a
107// child process. Deliberately strict: letters, digits, dot, colon (IPv6) and
108// hyphen. Rejects everything a shell would treat as syntax, so an inventory
109// entry can never become a command even if a future call site drops execFile.
110function isSafeHost (host) {
111 return typeof host === 'string' && host.length > 0 && host.length <= 255 &&
112 /^[A-Za-z0-9.:-]+$/.test(host)
113}
114
77115function probeNode (relay) {
78116 return new Promise((resolve) => {
79117 const ip = relay.publicIp
……@@ -103,14 +141,31 @@ function probeNode (relay) {
103141 'printf \'{"version":"%s","diskPct":%s,"diskGB":%s,"swapMB":%s,"ramUsedPct":%s,"loadAvg":%s,"uptime":"%s","restarts":%s,"memCurrentMB":%s,"tasks":%s,"env":"%s","watchdog":"%s","healthOk":%s,"config":%s,"status":%s}\\n\' "$V" "${D:-0}" "${DG:-0}" "${SW:-0}" "${RM:-0}" "${LA:-0}" "$UP" "${RT:-0}" "${MC:-0}" "${TS:-0}" "$ENV" "$WD" "$HO" "$CFG" "$STATUS"'
104142 ].join('\n')
105143
144 // `ip` comes from the inventory, and POST /api/relay appends to that
145 // inventory after validating only truthiness — so it is caller-controlled.
146 // Interpolating it into a shell string made this a remote command-execution
147 // sink, re-triggered every 60s by the probe interval. execFile takes an argv
148 // array and spawns no shell, so the value can only ever be an argument.
149 if (!isSafeHost(ip)) {
150 return resolve({ ...makeOffline(relay), note: 'invalid host in inventory' })
151 }
152
106153 // timeout is required: macOS nc -w is unreliable and can hang forever on dead hosts
107 exec(`nc -z -G 3 -w 3 ${ip} 22 2>&1`, { timeout: 6000 }, (tcpErr) => {
154 execFile('nc', ['-z', '-G', '3', '-w', '3', ip, '22'], { timeout: 6000 }, (tcpErr) => {
108155 if (tcpErr) {
109156 return resolve(makeOffline(relay))
110157 }
111158
112 const sshBase = `ssh ${keyArg.join(' ')} -o IdentitiesOnly=yes -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@${ip}`
113 exec(`${sshBase} '${remote.replace(/'/g, "'\\''")}' 2>/dev/null`, { timeout: 25000 }, (err, stdout) => {
159 const sshArgs = [
160 ...keyArg,
161 '-o', 'IdentitiesOnly=yes',
162 '-o', 'ConnectTimeout=8',
163 '-o', 'BatchMode=yes',
164 '-o', 'StrictHostKeyChecking=accept-new',
165 `root@${ip}`,
166 remote
167 ]
168 execFile('ssh', sshArgs, { timeout: 25000 }, (err, stdout) => {
114169 if (err || !stdout || !stdout.trim().startsWith('{')) {
115170 return resolve({ ...makeOffline(relay), reachable: true, status: 'degraded' })
116171 }
……@@ -295,14 +350,23 @@ function sshAction (relay, command, label) {
295350// --- HTTP server ----------------------------------------------------------
296351
297352const server = http.createServer(async (req, res) => {
353 // No wildcard CORS. A browser on any origin could otherwise drive this
354 // control plane with the operator's own loopback access.
298355 if (req.method === 'OPTIONS') {
299 res.writeHead(204, { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type' })
356 res.writeHead(204, { 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type, authorization, x-admin-token' })
300357 return res.end()
301358 }
302359
360 // Auth gate ahead of every route, including the static UI and the SSE stream:
361 // the stream leaks the full inventory and action log.
362 if (!tokenOk(presentedToken(req))) {
363 res.writeHead(401, { 'content-type': 'application/json' })
364 return res.end(JSON.stringify({ error: 'unauthorized' }))
365 }
366
303367 // SSE
304368 if (req.url === '/api/events') {
305 res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'connection': 'keep-alive', 'access-control-allow-origin': '*' })
369 res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'connection': 'keep-alive' })
306370 res.write(`data: ${JSON.stringify({ type: 'fleet', data: fleetState, timestamp: lastProbe })}\n\n`)
307371 res.write(`data: ${JSON.stringify({ type: 'actions', data: actionLog })}\n\n`)
308372 sseClients.push(res)
……@@ -395,6 +459,15 @@ const server = http.createServer(async (req, res) => {
395459 if (req.url === '/api/relay' && req.method === 'POST') {
396460 const body = await readBody(req)
397461 if (!body.name || !body.publicIp) return sendJson(res, 400, { error: 'name and publicIp required' })
462 // Validate at the boundary, not only at the call site. This value is
463 // persisted to the inventory and then handed to ssh every 60s; a truthiness
464 // check is not enough to let it become an argv element.
465 if (!isSafeHost(body.publicIp)) {
466 return sendJson(res, 400, { error: 'publicIp must be a hostname or IP address' })
467 }
468 if (!/^[A-Za-z0-9._-]{1,64}$/.test(body.name)) {
469 return sendJson(res, 400, { error: 'name must be 1-64 chars of [A-Za-z0-9._-]' })
470 }
398471
399472 const relays = loadRelays()
400473 if (relays.find(r => r.name === body.name)) {
……@@ -619,8 +692,16 @@ async function main () {
619692 console.log(`[admin-operator] ${relays.length} nodes configured`)
620693 // Listen first so UI is available while the (slow) fleet probe runs
621694 await new Promise((resolve, reject) => {
622 server.listen(PORT, () => {
623 console.log(`\n [admin-operator] Dashboard Admin Operator → http://localhost:${PORT}\n`)
695 server.listen(PORT, HOST, () => {
696 console.log(`\n [admin-operator] Dashboard Admin Operator → http://${HOST}:${PORT}/?token=${TOKEN}\n`)
697 if (TOKEN_WAS_GENERATED) {
698 console.log(' [admin-operator] Generated a one-off token for this run.')
699 console.log(' [admin-operator] Set HIVERELAY_ADMIN_TOKEN to pin it across restarts.\n')
700 }
701 if (HOST !== '127.0.0.1' && HOST !== 'localhost') {
702 console.log(` [admin-operator] WARNING: bound to ${HOST}, not loopback. This process can`)
703 console.log(' [admin-operator] run commands as root on every relay in the inventory.\n')
704 }
624705 resolve()
625706 })
626707 server.on('error', reject)
packages/core/config/default.js +9 -1
……@@ -202,7 +202,15 @@ export default {
202202 pairing: {
203203 enabled: false
204204 },
205 serviceDefaultPeerRole: 'authenticated-user',
205 // Anonymous swarm peers get the anonymous role. Raising this default grants
206 // every unauthenticated peer that reaches the service router the rights of a
207 // known user — on a public relay that is the whole internet.
208 //
209 // This file is the one the CLI actually loads, and it is the one commit
210 // 9125f3c missed: that commit set 'anonymous' in relay-node/index.js and
211 // bare-relay.js, but the value here is merged in AFTER those defaults, so it
212 // won. A default install was silently back on 'authenticated-user'.
213 serviceDefaultPeerRole: 'anonymous',
206214 serviceAdminAllowlist: [],
207215 enableServices: false,
208216 plugins: [],
test/unit/config-loader.test.js +25 -0
……@@ -35,6 +35,31 @@ test('config loader: saving nested default edits does not mutate defaults away',
3535 t.is(loadConfig().subsidy.payoutDestination, 'operator@example.com')
3636})
3737
38test('config loader: a default relay grants anonymous peers the anonymous role', async (t) => {
39 // Regression guard for a security default that silently reverted. Commit
40 // 9125f3c set 'anonymous' in relay-node/index.js and bare-relay.js but not in
41 // config/default.js — and config/default.js is the one the CLI loads, merged
42 // last, so it won. Every default install was back on 'authenticated-user',
43 // which hands any anonymous swarm peer the rights of a known user (notably
44 // arbitration.submit, whose pending Map is uncapped).
45 //
46 // Nothing asserted this value in either file, which is exactly why the
47 // revert went unnoticed. Assert the EFFECTIVE value, not the literal, so this
48 // still fails if the precedence between the two defaults changes again.
49 const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-peer-role-'))
50 t.teardown(async () => {
51 await rm(home, { recursive: true, force: true })
52 })
53
54 const { loadConfig } = await importLoaderWithHome(home)
55
56 t.is(loadConfig().serviceDefaultPeerRole, 'anonymous',
57 'default install must not grant authenticated-user to anonymous peers')
58 t.is(loadConfig({ serviceDefaultPeerRole: 'authenticated-user' }).serviceDefaultPeerRole,
59 'authenticated-user',
60 'an operator can still raise it deliberately')
61})
62
3863test('cli start rejects invalid HIVERELAY_ACCEPT_MODE before boot', async (t) => {
3964 const home = await mkdtemp(path.join(tmpdir(), 'hiverelay-cli-env-'))
4065 t.teardown(async () => {