Skip to content

Commit d1ee021

Browse files
beacon: prober dials a curated allowlist only; default daily
Security (safe-then-expand): the prober no longer reads the harvested relays directory for targets — that set is attacker-influenceable (anyone can publish a relay list we harvest), which would let the prober be used to dial arbitrary external hosts. It now dials ONLY a small built-in allowlist of well-known relays, expandable via PROBE_RELAYS. - src/prober.js: DEFAULT_PROBE_RELAYS + proberRelays(env); sweepOnce uses it (drops the db.relays.find target query); all entries screened via safeRelayUrl. Default interval 1h -> 24h (daily). USAGE updated. - .env.example: document PROBE_RELAYS + daily default. - tests: proberRelays (default allowlist; PROBE_RELAYS override drops loopback/junk) + interval default 86400000. npm test 64/64. Live --once: writes exactly the 8 allowlist relays, none from the directory. Closes #27
1 parent fc2d3b1 commit d1ee021

3 files changed

Lines changed: 47 additions & 18 deletions

File tree

.env.example

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ PORT=3000
1919
# writing collections still owned by the legacy firehose processes.
2020
# INDEX_LEGACY_KINDS=1
2121

22-
# Relay-health prober (node probe.js / npm run probe). Dials every relay in the
23-
# directory, writing online/uptime/latency + NIP-11 auth/payment flags.
24-
# PROBE_INTERVAL=3600000 # sweep cadence in ms (default 1h)
22+
# Relay-health prober (node probe.js / npm run probe). Dials ONLY a curated
23+
# allowlist (never the harvested directory) and writes online/uptime/latency +
24+
# NIP-11 auth/payment flags. Expand the allowlist deliberately.
25+
# PROBE_RELAYS=wss://relay.damus.io,wss://nos.lol # allowlist (default: small built-in set)
26+
# PROBE_INTERVAL=86400000 # sweep cadence in ms (default 86400000 = daily)
2527
# PROBE_CONCURRENCY=25 # relays probed in parallel
2628
# PROBE_TIMEOUT=7000 # per-relay connect/HTTP timeout in ms
2729
# RUN_ONCE=1 # single sweep then exit (or pass --once)

src/prober.js

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ import 'dotenv/config';
1212

1313
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
1414

15+
// SECURITY: the prober dials ONLY this curated allowlist — never the harvested
16+
// `relays` directory, which is attacker-influenceable (anyone can publish a
17+
// relay list we harvest). Decoupling the target set from attacker-controlled
18+
// data is what stops the prober being used to dial arbitrary hosts. Expand
19+
// deliberately via PROBE_RELAYS (comma list); safe-then-expand, not the reverse.
20+
export const DEFAULT_PROBE_RELAYS = [
21+
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net',
22+
'wss://relay.nostr.band', 'wss://nostr.wine', 'wss://relay.snort.social',
23+
'wss://purplepag.es', 'wss://nostr.mom',
24+
];
25+
26+
/** The curated set of relays the prober is allowed to dial (screened). */
27+
export function proberRelays(env = process.env) {
28+
const raw = env.PROBE_RELAYS ? env.PROBE_RELAYS.split(',') : DEFAULT_PROBE_RELAYS;
29+
return [...new Set(raw.map((u) => safeRelayUrl(String(u).trim())).filter(Boolean))];
30+
}
31+
1532
export const USAGE = `beacon relay-health prober
1633
1734
Usage: node probe.js [flags] (or: node src/prober.js [flags])
@@ -20,8 +37,12 @@ Flags (override the matching env var):
2037
--once run a single sweep and exit (env RUN_ONCE=1)
2138
--concurrency <n> relays probed in parallel (env PROBE_CONCURRENCY, default 25)
2239
--timeout <ms> per-relay connect/HTTP timeout (env PROBE_TIMEOUT, default 7000)
23-
--interval <ms> sweep interval when scheduling (env PROBE_INTERVAL, default 3600000)
24-
-h, --help show this help`;
40+
--interval <ms> sweep interval when scheduling (env PROBE_INTERVAL, default 86400000 = daily)
41+
-h, --help show this help
42+
43+
Targets: a curated allowlist only (env PROBE_RELAYS, comma list; default: a
44+
small built-in set of well-known relays). The harvested directory is NEVER
45+
dialed — expand the allowlist deliberately.`;
2546

2647
/** Parse prober CLI flags into an overlay (only keys the user passed). */
2748
export function parseArgs(argv = process.argv.slice(2)) {
@@ -43,7 +64,7 @@ export function proberConfig(env = process.env, argv = process.argv.slice(2)) {
4364
const flags = parseArgs(argv);
4465
const pos = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
4566
return {
46-
interval: flags.interval ?? pos(env.PROBE_INTERVAL, 3_600_000),
67+
interval: flags.interval ?? pos(env.PROBE_INTERVAL, 86_400_000),
4768
concurrency: flags.concurrency ?? pos(env.PROBE_CONCURRENCY, 25),
4869
timeout: flags.timeout ?? pos(env.PROBE_TIMEOUT, 7_000),
4970
once: flags.once || env.RUN_ONCE === '1' || env.RUN_ONCE === 'true',
@@ -123,21 +144,16 @@ async function mapPool(items, concurrency, fn) {
123144
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, worker));
124145
}
125146

126-
/** One full sweep over every relay in the directory. Returns { total, online }. */
147+
/** One sweep over the curated allowlist (never the harvested directory). */
127148
export async function sweepOnce(db, cfg) {
128-
const docs = await db.collection(RELAY_DIRECTORY).find({}, { projection: { relay: 1, _id: 0 } }).toArray();
129-
const all = [...new Set(docs.map((d) => d.relay).filter(Boolean))];
130-
// Defense-in-depth: never dial loopback/private/reserved targets, even if
131-
// junk slipped into the directory before harvest hardening (SSRF guard).
132-
const relays = all.filter((r) => safeRelayUrl(r));
133-
const skipped = all.length - relays.length;
149+
const relays = proberRelays(); // allowlist only — attacker-fed data is never dialed
134150
let online = 0;
135151
await mapPool(relays, cfg.concurrency, async (relay) => {
136152
try { if ((await probeRelay(db, relay, cfg)).online) online++; }
137153
catch (e) { console.error(`[beacon] probe error ${relay}: ${e.message}`); }
138154
});
139-
console.log(`[beacon] relay sweep: ${online}/${relays.length} online${skipped ? ` (skipped ${skipped} unsafe)` : ''}`);
140-
return { total: relays.length, online, skipped };
155+
console.log(`[beacon] relay sweep (allowlist): ${online}/${relays.length} online`);
156+
return { total: relays.length, online };
141157
}
142158

143159
export async function runProber() {

test/prober.test.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// manual smoke run, not here.
44
import test from 'node:test';
55
import assert from 'node:assert/strict';
6-
import { parseArgs, proberConfig, relayInfoUrl, nip11Flags } from '../src/prober.js';
6+
import { parseArgs, proberConfig, relayInfoUrl, nip11Flags, proberRelays, DEFAULT_PROBE_RELAYS } from '../src/prober.js';
77

88
test('parseArgs: flags', () => {
99
assert.deepEqual(parseArgs(['--once']), { once: true });
@@ -18,14 +18,25 @@ test('parseArgs: non-numeric value is ignored (not consumed as flag value)', ()
1818
assert.deepEqual(parseArgs(['--concurrency', 'abc']), {});
1919
});
2020

21-
test('proberConfig: defaults', () => {
21+
test('proberConfig: defaults (daily)', () => {
2222
const c = proberConfig({}, []);
23-
assert.equal(c.interval, 3_600_000);
23+
assert.equal(c.interval, 86_400_000);
2424
assert.equal(c.concurrency, 25);
2525
assert.equal(c.timeout, 7_000);
2626
assert.equal(c.once, false);
2727
});
2828

29+
test('proberRelays: default is the built-in allowlist (screened)', () => {
30+
const r = proberRelays({});
31+
assert.equal(r.length, DEFAULT_PROBE_RELAYS.length);
32+
assert.ok(r.every((u) => u.startsWith('wss://')));
33+
});
34+
35+
test('proberRelays: PROBE_RELAYS overrides; junk/SSRF entries dropped', () => {
36+
const r = proberRelays({ PROBE_RELAYS: 'wss://relay.example.com, ws://127.0.0.1/, garbage, wss://nos.lol' });
37+
assert.deepEqual(r, ['wss://relay.example.com/', 'wss://nos.lol/']); // loopback + junk screened out
38+
});
39+
2940
test('proberConfig: env applies, non-positive falls back to default', () => {
3041
const c = proberConfig({ PROBE_CONCURRENCY: '40', PROBE_TIMEOUT: '0', RUN_ONCE: '1' }, []);
3142
assert.equal(c.concurrency, 40);

0 commit comments

Comments
 (0)