|
1 | 1 | // 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. |
5 | 10 | const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays'; |
6 | 11 |
|
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 |
16 | 39 | } |
17 | 40 |
|
| 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 | + |
18 | 57 | export async function ensureRelayDirectoryIndex(db) { |
19 | 58 | await db.collection(RELAY_DIRECTORY).createIndex({ relay: 1 }) |
20 | 59 | .catch((e) => { if (e?.code !== 85 && e?.code !== 86) throw e; }); |
21 | 60 | } |
22 | 61 |
|
23 | 62 | /** |
24 | 63 | * 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. |
27 | 67 | */ |
28 | 68 | 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); |
30 | 70 | if (!uniq.length) return 0; |
31 | 71 | await db.collection(RELAY_DIRECTORY).bulkWrite( |
32 | 72 | uniq.map((relay) => ({ updateOne: { filter: { relay }, update: { $setOnInsert: { relay } }, upsert: true } })), |
|
0 commit comments