Skip to content

Commit 4a25649

Browse files
Merge pull request #20 from JavaScriptSolidServer/issue-19-relay-ssrf-hardening
Security: relay-harvest SSRF hardening + bomb cap + junk cleanup
2 parents 5ec7fde + 4e87f21 commit 4a25649

5 files changed

Lines changed: 142 additions & 20 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"start": "node index.js",
1212
"index": "node -e \"import('./src/indexer.js').then(m => m.runIndexer())\"",
1313
"serve": "node -e \"import('./src/server.js').then(m => m.startServer())\"",
14-
"probe": "node probe.js"
14+
"probe": "node probe.js",
15+
"clean:relays": "node scripts/clean-relays.js"
1516
},
1617
"dependencies": {
1718
"@noble/curves": "^2.2.0",

scripts/clean-relays.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// One-off: purge relay-directory docs whose URL fails the SSRF/format screen
2+
// (loopback, private/reserved IPs, .onion, IP-literals, malformed hosts).
3+
// Uses the exact same `safeRelayUrl` the harvest + prober now enforce.
4+
//
5+
// node scripts/clean-relays.js --dry # report only
6+
// node scripts/clean-relays.js # delete
7+
import { connect, close } from '../src/db.js';
8+
import { safeRelayUrl } from '../src/hoses/relays.js';
9+
import 'dotenv/config';
10+
11+
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
12+
const DRY = process.argv.includes('--dry');
13+
14+
const db = await connect();
15+
const col = db.collection(RELAY_DIRECTORY);
16+
const before = await col.estimatedDocumentCount();
17+
18+
const bad = [];
19+
for await (const d of col.find({}, { projection: { relay: 1 } })) {
20+
if (!d.relay || !safeRelayUrl(d.relay)) bad.push(d._id);
21+
}
22+
console.log(`[clean] ${before} relays, ${bad.length} unsafe/malformed (${DRY ? 'dry run' : 'deleting'})`);
23+
24+
if (bad.length && !DRY) {
25+
for (let i = 0; i < bad.length; i += 1000) {
26+
await col.deleteMany({ _id: { $in: bad.slice(i, i + 1000) } });
27+
}
28+
console.log(`[clean] deleted ${bad.length}; ${await col.estimatedDocumentCount()} remain`);
29+
}
30+
await close();

src/hoses/relays.js

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,72 @@
11
// Shared relay-directory helpers, used by any hose that discovers relay URLs
2-
// (follows harvests them from legacy kind-3 content; relay-lists from kind-3…
3-
// sorry, kind-10002 `r` tags). Discovery only seeds the URL — it never touches
4-
// the health metrics the relay-health prober owns.
2+
// (follows harvests them from legacy kind-3 content; relay-lists from kind-10002
3+
// `r` tags) and by the relay-health prober.
4+
//
5+
// SECURITY: relay URLs come from arbitrary, attacker-controllable Nostr events.
6+
// `safeRelayUrl` is the single chokepoint that canonicalizes a URL and rejects
7+
// anything we must never dial — loopback, private/reserved ranges, link-local,
8+
// IP-literals, .onion, and malformed hosts — so the prober can't be turned into
9+
// an SSRF amplifier against our own host or internal network.
510
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
611

7-
// Trailing slash on bare origins (so `wss://r.com` and `wss://r.com/` dedupe);
8-
// keep an explicit path as-is. Non-ws(s) or unparseable URLs return null.
9-
export function canonicalizeRelayUrl(url) {
10-
try {
11-
const u = new URL(url);
12-
if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return null;
13-
if (u.pathname === '' || u.pathname === '/') return `${u.protocol}//${u.host}/`;
14-
return url;
15-
} catch { return null; }
12+
// Max relay URLs harvested from a single event — a real NIP-65 list is tiny;
13+
// a 9,000-tag event is a bomb. Truncate rather than ingest the lot.
14+
export const MAX_HARVEST_PER_EVENT = Number(process.env.RELAY_HARVEST_CAP) || 100;
15+
16+
const DNS_NAME = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
17+
18+
// Is this hostname a private / loopback / reserved / otherwise un-probeable
19+
// target we must refuse? Operates on the literal host (no DNS resolution).
20+
function isBlockedHost(host) {
21+
const h = host.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 brackets
22+
if (!h) return true;
23+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
24+
if (h.endsWith('.onion')) return true; // can't reach without Tor
25+
if (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true; // IPv6 loopback/link-local/ULA
26+
// IPv4 literal?
27+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
28+
if (m) {
29+
const [a, b] = [Number(m[1]), Number(m[2])];
30+
if (a === 10 || a === 127 || a === 0 || a >= 224) return true; // private/loopback/this-host/multicast+reserved
31+
if (a === 169 && b === 254) return true; // link-local incl. cloud metadata
32+
if (a === 192 && b === 168) return true;
33+
if (a === 172 && b >= 16 && b <= 31) return true;
34+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
35+
return true; // any other bare IPv4 literal: relays should use DNS names
36+
}
37+
if (/^[0-9a-f:]+$/i.test(h) && h.includes(':')) return true; // any other IPv6 literal
38+
return !DNS_NAME.test(h); // require a real DNS hostname
1639
}
1740

41+
/**
42+
* Canonicalize + security-screen a relay URL. Returns the normalized wss/ws URL
43+
* (trailing slash on bare origins), or null if it must not be stored/dialed.
44+
*/
45+
export function safeRelayUrl(url) {
46+
let u;
47+
try { u = new URL(String(url)); } catch { return null; }
48+
if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return null;
49+
if (isBlockedHost(u.hostname)) return null;
50+
if (u.pathname === '' || u.pathname === '/') return `${u.protocol}//${u.host}/`;
51+
return u.toString();
52+
}
53+
54+
// Back-compat alias (older callers/tests): same screening as safeRelayUrl.
55+
export const canonicalizeRelayUrl = safeRelayUrl;
56+
1857
export async function ensureRelayDirectoryIndex(db) {
1958
await db.collection(RELAY_DIRECTORY).createIndex({ relay: 1 })
2059
.catch((e) => { if (e?.code !== 85 && e?.code !== 86) throw e; });
2160
}
2261

2362
/**
2463
* Best-effort discovery: seed newly-seen relay URLs into the directory without
25-
* touching any existing fields ($setOnInsert). Canonicalizes + dedupes first.
26-
* Never throws — discovery must never fail an ingest. Returns the count seeded.
64+
* touching any existing fields ($setOnInsert). Screens + dedupes first, and
65+
* caps how many it accepts from one event (bomb protection). Never throws.
66+
* Returns the count seeded.
2767
*/
2868
export async function harvestRelays(db, urls) {
29-
const uniq = [...new Set((urls || []).map(canonicalizeRelayUrl).filter(Boolean))];
69+
const uniq = [...new Set((urls || []).map(safeRelayUrl).filter(Boolean))].slice(0, MAX_HARVEST_PER_EVENT);
3070
if (!uniq.length) return 0;
3171
await db.collection(RELAY_DIRECTORY).bulkWrite(
3272
uniq.map((relay) => ({ updateOne: { filter: { relay }, update: { $setOnInsert: { relay } }, upsert: true } })),

src/prober.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// The relay universe is the `relays` collection itself, continuously seeded by
88
// the follows/relay-lists hoses' URL harvesting.
99
import { connect } from './db.js';
10-
import { ensureRelayDirectoryIndex } from './hoses/relays.js';
10+
import { ensureRelayDirectoryIndex, safeRelayUrl } from './hoses/relays.js';
1111
import 'dotenv/config';
1212

1313
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
@@ -126,14 +126,18 @@ async function mapPool(items, concurrency, fn) {
126126
/** One full sweep over every relay in the directory. Returns { total, online }. */
127127
export async function sweepOnce(db, cfg) {
128128
const docs = await db.collection(RELAY_DIRECTORY).find({}, { projection: { relay: 1, _id: 0 } }).toArray();
129-
const relays = [...new Set(docs.map((d) => d.relay).filter(Boolean))];
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;
130134
let online = 0;
131135
await mapPool(relays, cfg.concurrency, async (relay) => {
132136
try { if ((await probeRelay(db, relay, cfg)).online) online++; }
133137
catch (e) { console.error(`[beacon] probe error ${relay}: ${e.message}`); }
134138
});
135-
console.log(`[beacon] relay sweep: ${online}/${relays.length} online`);
136-
return { total: relays.length, online };
139+
console.log(`[beacon] relay sweep: ${online}/${relays.length} online${skipped ? ` (skipped ${skipped} unsafe)` : ''}`);
140+
return { total: relays.length, online, skipped };
137141
}
138142

139143
export async function runProber() {

test/relay-safety.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// safeRelayUrl is the SSRF/format chokepoint for harvested relay URLs.
2+
// Regression net for the relay-bomb incident (kind-10002 r-tag bombs full of
3+
// loopback/private/.onion targets the prober would otherwise have dialed).
4+
import test from 'node:test';
5+
import assert from 'node:assert/strict';
6+
import { safeRelayUrl, MAX_HARVEST_PER_EVENT } from '../src/hoses/relays.js';
7+
8+
test('accepts normal relays; canonicalizes bare origins', () => {
9+
assert.equal(safeRelayUrl('wss://relay.damus.io'), 'wss://relay.damus.io/');
10+
assert.equal(safeRelayUrl('wss://relay.damus.io/'), 'wss://relay.damus.io/');
11+
assert.equal(safeRelayUrl('wss://nos.lol/inbox'), 'wss://nos.lol/inbox');
12+
assert.equal(safeRelayUrl('ws://relay.example.com'), 'ws://relay.example.com/');
13+
});
14+
15+
test('rejects loopback / localhost (would hit our own host)', () => {
16+
for (const u of ['wss://localhost', 'wss://localhost:3001', 'ws://127.0.0.1:4848/',
17+
'wss://app.localhost', 'wss://relay.local', 'wss://[::1]/', 'wss://0.0.0.0/']) {
18+
assert.equal(safeRelayUrl(u), null, u);
19+
}
20+
});
21+
22+
test('rejects private / reserved / link-local ranges', () => {
23+
for (const u of ['ws://10.0.0.1:9173/', 'wss://192.168.1.5/', 'wss://172.16.0.1/',
24+
'wss://172.31.255.255/', 'wss://169.254.169.254/', 'wss://100.64.0.1/', 'wss://fe80::1/', 'wss://fc00::1/']) {
25+
assert.equal(safeRelayUrl(u), null, u);
26+
}
27+
});
28+
29+
test('rejects all bare IP-literals (relays must use DNS names)', () => {
30+
assert.equal(safeRelayUrl('wss://8.8.8.8/'), null); // public IP, still rejected
31+
assert.equal(safeRelayUrl('wss://1.2.3.4:7000/'), null);
32+
});
33+
34+
test('rejects .onion (unreachable without Tor)', () => {
35+
assert.equal(safeRelayUrl('wss://abcdefghij234567.onion/'), null);
36+
});
37+
38+
test('rejects malformed / double-scheme / non-ws', () => {
39+
for (const u of ['garbage', '', 'https://relay.example.com', 'wss://http://nostr-01.example.com',
40+
'wss://', 'wss://nodot', 'http://relay.example.com']) {
41+
assert.equal(safeRelayUrl(u), null, JSON.stringify(u));
42+
}
43+
});
44+
45+
test('harvest cap is sane (bomb protection)', () => {
46+
assert.ok(MAX_HARVEST_PER_EVENT >= 1 && MAX_HARVEST_PER_EVENT <= 1000);
47+
});

0 commit comments

Comments
 (0)