4545import crypto from 'node:crypto' ;
4646import fs from 'node:fs' ;
4747import path from 'node:path' ;
48+ import net from 'node:net' ;
49+ import { lookup } from 'node:dns/promises' ;
50+
51+ // --------------------------------------------------------------- SSRF gate
52+ //
53+ // The inbox is unauthenticated-by-design in Phase 1 (inbound HTTP-Signature
54+ // verification is the Phase-2 boundary — see README), so a Follow's
55+ // attacker-controlled `actor` URL is fetched (to resolve the delivery inbox)
56+ // and later POSTed to. That is an SSRF surface. The mitigation, until real
57+ // signature verification lands, is this gate — a port of corsproxy/'s
58+ // private-address guard: outbound delivery targets must be http/https AND
59+ // every resolved address must be public (loopback/RFC1918/link-local incl.
60+ // the 169.254.169.254 cloud-metadata endpoint/CGNAT/ULA/unspecified are all
61+ // refused), failing CLOSED on any resolution error. A target that fails the
62+ // gate is skipped silently (delivery returns null) — the inbox POST itself
63+ // still succeeds so normal federation semantics are preserved.
64+ const V4_BLOCKED_CIDRS = [
65+ '0.0.0.0/8' , // "this network" / unspecified
66+ '10.0.0.0/8' , // RFC1918
67+ '100.64.0.0/10' , // CGNAT
68+ '127.0.0.0/8' , // loopback
69+ '169.254.0.0/16' , // link-local (incl. 169.254.169.254 cloud metadata)
70+ '172.16.0.0/12' , // RFC1918
71+ '192.0.0.0/24' , // IETF protocol assignments
72+ '192.0.2.0/24' , // TEST-NET-1
73+ '192.168.0.0/16' , // RFC1918
74+ '198.18.0.0/15' , // benchmarking
75+ '198.51.100.0/24' , // TEST-NET-2
76+ '203.0.113.0/24' , // TEST-NET-3
77+ '224.0.0.0/4' , // multicast
78+ '240.0.0.0/4' , // reserved + broadcast
79+ ] ;
80+
81+ function v4ToInt ( ip ) {
82+ const [ a , b , c , d ] = ip . split ( '.' ) . map ( Number ) ;
83+ return ( ( a << 24 ) >>> 0 ) + ( b << 16 ) + ( c << 8 ) + d ;
84+ }
85+
86+ const V4_BLOCKED = V4_BLOCKED_CIDRS . map ( ( cidr ) => {
87+ const [ base , bits ] = cidr . split ( '/' ) ;
88+ const mask = ( 0xffffffff << ( 32 - Number ( bits ) ) ) >>> 0 ;
89+ return { base : ( v4ToInt ( base ) & mask ) >>> 0 , mask } ;
90+ } ) ;
91+
92+ function isPrivateV4 ( ip ) {
93+ const n = v4ToInt ( ip ) ;
94+ if ( ! Number . isFinite ( n ) ) return true ; // malformed — fail closed
95+ return V4_BLOCKED . some ( ( { base, mask } ) => ( ( n & mask ) >>> 0 ) === base ) ;
96+ }
97+
98+ /** Expand an IPv6 literal to its 8 16-bit groups (null when malformed). */
99+ function expandV6 ( ip ) {
100+ let addr = ip . split ( '%' ) [ 0 ] . toLowerCase ( ) ;
101+ const dotted = addr . match ( / ( \d { 1 , 3 } (?: \. \d { 1 , 3 } ) { 3 } ) $ / ) ;
102+ if ( dotted ) {
103+ const [ a , b , c , d ] = dotted [ 1 ] . split ( '.' ) . map ( Number ) ;
104+ addr = addr . slice ( 0 , - dotted [ 1 ] . length )
105+ + ( ( a << 8 ) | b ) . toString ( 16 ) + ':' + ( ( c << 8 ) | d ) . toString ( 16 ) ;
106+ }
107+ const [ headStr , tailStr = '' ] = addr . split ( '::' ) ;
108+ const head = headStr ? headStr . split ( ':' ) : [ ] ;
109+ const tail = tailStr ? tailStr . split ( ':' ) : [ ] ;
110+ const groups = addr . includes ( '::' )
111+ ? [ ...head , ...Array ( Math . max ( 0 , 8 - head . length - tail . length ) ) . fill ( '0' ) , ...tail ]
112+ : head ;
113+ if ( groups . length !== 8 ) return null ;
114+ const nums = groups . map ( ( g ) => parseInt ( g || '0' , 16 ) ) ;
115+ return nums . some ( ( n ) => ! Number . isFinite ( n ) || n < 0 || n > 0xffff ) ? null : nums ;
116+ }
117+
118+ function isPrivateV6 ( ip ) {
119+ const g = expandV6 ( ip ) ;
120+ if ( ! g ) return true ; // malformed — fail closed
121+ const zeroThrough = ( i ) => g . slice ( 0 , i ) . every ( ( n ) => n === 0 ) ;
122+ const embeddedV4 = ( ) => `${ g [ 6 ] >> 8 } .${ g [ 6 ] & 0xff } .${ g [ 7 ] >> 8 } .${ g [ 7 ] & 0xff } ` ;
123+ if ( zeroThrough ( 5 ) && g [ 5 ] === 0xffff ) return isPrivateV4 ( embeddedV4 ( ) ) ; // ::ffff:v4
124+ if ( g [ 0 ] === 0x64 && g [ 1 ] === 0xff9b && g . slice ( 2 , 6 ) . every ( ( n ) => n === 0 ) ) {
125+ return isPrivateV4 ( embeddedV4 ( ) ) ; // 64:ff9b::/96 NAT64
126+ }
127+ if ( g . every ( ( n ) => n === 0 ) ) return true ; // :: unspecified
128+ if ( zeroThrough ( 7 ) && g [ 7 ] === 1 ) return true ; // ::1 loopback
129+ if ( ( g [ 0 ] & 0xfe00 ) === 0xfc00 ) return true ; // fc00::/7 ULA
130+ if ( ( g [ 0 ] & 0xffc0 ) === 0xfe80 ) return true ; // fe80::/10 link-local
131+ if ( ( g [ 0 ] & 0xffc0 ) === 0xfec0 ) return true ; // fec0::/10 site-local
132+ if ( g [ 0 ] === 0x2001 && g [ 1 ] === 0x0db8 ) return true ; // 2001:db8::/32 documentation
133+ return false ;
134+ }
135+
136+ function isPrivateIp ( ip ) {
137+ const kind = net . isIP ( ip ) ;
138+ if ( kind === 4 ) return isPrivateV4 ( ip ) ;
139+ if ( kind === 6 ) return isPrivateV6 ( ip ) ;
140+ return true ; // not an IP literal — fail closed
141+ }
142+
143+ /** True only if hostname resolves and EVERY address is public. Fail closed. */
144+ async function resolvesToPublic ( hostname ) {
145+ const host = hostname . replace ( / ^ \[ | \] $ / g, '' ) . toLowerCase ( ) ;
146+ if ( net . isIP ( host ) ) return ! isPrivateIp ( host ) ;
147+ let records ;
148+ try { records = await lookup ( host , { all : true , verbatim : true } ) ; }
149+ catch { return false ; } // unresolvable — fail closed
150+ if ( ! records ?. length ) return false ; // zero addresses — fail closed
151+ return records . every ( ( { address } ) => ! isPrivateIp ( address ) ) ;
152+ }
153+
154+ /**
155+ * Fetch a delivery target behind the SSRF gate. Scheme must be http/https and
156+ * (unless cfg.allowPrivateDelivery) every resolved hop must be public.
157+ * Redirects are followed MANUALLY and each hop is re-validated (redirect:
158+ * 'follow' would let a public URL 302 to 169.254.169.254). Returns the
159+ * Response, or null if the target is refused / unreachable — callers treat
160+ * null as "skip delivery". The caller owns consuming res.body.
161+ */
162+ async function gatedFetch ( startUrl , init , cfg , { follow = true , maxHops = 5 } = { } ) {
163+ let current ;
164+ try { current = new URL ( String ( startUrl ) . replace ( / # .* $ / , '' ) ) ; }
165+ catch { return null ; }
166+ for ( let hop = 0 ; ; hop ++ ) {
167+ if ( current . protocol !== 'http:' && current . protocol !== 'https:' ) return null ;
168+ if ( ! cfg . allowPrivateDelivery && ! ( await resolvesToPublic ( current . hostname ) ) ) return null ;
169+ let res ;
170+ try { res = await fetch ( current , { ...init , redirect : 'manual' } ) ; }
171+ catch { return null ; }
172+ const location = res . headers . get ( 'location' ) ;
173+ if ( res . status < 300 || res . status >= 400 || ! location || ! follow || hop >= maxHops ) return res ;
174+ await res . body ?. cancel ( ) . catch ( ( ) => { } ) ; // drop the redirect body before the next hop
175+ try { current = new URL ( location , current ) ; }
176+ catch { return null ; }
177+ }
178+ }
48179
49180const AS_CONTEXT = 'https://www.w3.org/ns/activitystreams' ;
50181const SEC_CONTEXT = 'https://w3id.org/security/v1' ;
@@ -107,6 +238,36 @@ export async function activate(api) {
107238 fs . mkdirSync ( keysDir , { recursive : true } ) ;
108239 fs . mkdirSync ( stateDir , { recursive : true } ) ;
109240
241+ // Hardening knobs (all default-safe; see README "Findings"):
242+ // - allowPrivateDelivery: default FALSE — outbound delivery to
243+ // private/loopback/link-local targets is refused (SSRF gate). Set true
244+ // ONLY for local testing against a loopback inbox.
245+ // - maxInbox: cap the persisted inbox log (unauthenticated writers can
246+ // otherwise grow it without bound → disk/CPU DoS).
247+ // - maxResources: cap the loopback status walk in GET outbox (matches
248+ // backup/ + sparql/).
249+ const cfg = {
250+ allowPrivateDelivery : api . config . allowPrivateDelivery === true , // default closed
251+ maxInbox : Number . isFinite ( api . config . maxInbox ) && api . config . maxInbox > 0
252+ ? api . config . maxInbox : 500 ,
253+ maxResources : Number . isFinite ( api . config . maxResources ) && api . config . maxResources > 0
254+ ? api . config . maxResources : 1000 ,
255+ } ;
256+
257+ // Crash-safe write: land the bytes in a temp file, then atomically rename
258+ // over the target (atomic on the same fs). A crash mid-write can no longer
259+ // truncate the followers ledger / keypair — the old file survives intact.
260+ function writeFileAtomic ( file , data ) {
261+ const tmp = `${ file } .${ process . pid } .${ Date . now ( ) } .${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } .tmp` ;
262+ try {
263+ fs . writeFileSync ( tmp , data ) ;
264+ fs . renameSync ( tmp , file ) ;
265+ } catch ( e ) {
266+ try { fs . rmSync ( tmp , { force : true } ) ; } catch { /* ignore */ }
267+ throw e ;
268+ }
269+ }
270+
110271 // --------------------------------------------------------------- helpers
111272 const cors = ( reply ) => reply
112273 . header ( 'access-control-allow-origin' , '*' )
@@ -143,7 +304,7 @@ export async function activate(api) {
143304 privateKeyEncoding : { type : 'pkcs8' , format : 'pem' } ,
144305 } ) ;
145306 const kp = { publicKey, privateKey } ;
146- fs . writeFileSync ( file , JSON . stringify ( kp , null , 2 ) ) ;
307+ writeFileAtomic ( file , JSON . stringify ( kp , null , 2 ) ) ;
147308 return kp ;
148309 }
149310
@@ -154,7 +315,7 @@ export async function activate(api) {
154315 catch { return emptyState ( ) ; }
155316 }
156317 function saveState ( user , state ) {
157- fs . writeFileSync ( path . join ( stateDir , `${ user } .json` ) , JSON . stringify ( state , null , 2 ) ) ;
318+ writeFileAtomic ( path . join ( stateDir , `${ user } .json` ) , JSON . stringify ( state , null , 2 ) ) ;
158319 }
159320
160321 // ---- the actor document (AS2 Person + security context) ----------------
@@ -209,10 +370,15 @@ export async function activate(api) {
209370 if ( res . ok ) {
210371 const container = await res . json ( ) ;
211372 const contains = [ ] . concat ( container . contains ?? [ ] ) ;
373+ let fetched = 0 ;
212374 for ( const child of contains ) {
375+ // Cap the loopback walk so a huge status container can't turn one
376+ // GET outbox into an unbounded fan-out (matches backup/ + sparql/).
377+ if ( fetched >= cfg . maxResources ) break ;
213378 const cid = typeof child === 'string' ? child : child [ '@id' ] ;
214379 const m = cid && / \/ ( [ 0 - 9 ] + ) \. j s o n l d $ / . exec ( cid ) ;
215380 if ( ! m ) continue ;
381+ fetched += 1 ;
216382 const noteRes = await lb ( `/${ user } /${ STATUS_DIR } /${ m [ 1 ] } .jsonld` , {
217383 headers : { accept : 'application/ld+json' } , auth,
218384 } ) ;
@@ -250,26 +416,40 @@ export async function activate(api) {
250416 . sign ( kp . privateKey , 'base64' ) ;
251417 const sig = `keyId="${ keyId ( user ) } ",algorithm="rsa-sha256",`
252418 + `headers="(request-target) host date digest content-type",signature="${ signature } "` ;
253- return fetch ( inboxUrl , {
419+ // SSRF gate: the inbox URL came from an attacker-controlled actor doc, so
420+ // it is validated (public target only, unless allowPrivateDelivery) before
421+ // the POST. Redirects are NOT followed for delivery — a signed POST cannot
422+ // be replayed to a re-signed target — a 3xx just fails the delivery. The
423+ // response body is consumed on every path so no undici socket leaks.
424+ const res = await gatedFetch ( inboxUrl , {
254425 method : 'POST' ,
255426 headers : {
256427 host : u . host , date, digest, 'content-type' : AP_CT ,
257428 accept : AP_CT , signature : sig ,
258429 } ,
259430 body,
260- } ) ;
431+ } , cfg , { follow : false } ) ;
432+ if ( res ) await res . body ?. cancel ( ) . catch ( ( ) => { } ) ;
433+ return res ;
261434 }
262435
263- /** Fetch a remote actor's inbox URL (for delivery). null on any failure. */
436+ /**
437+ * Fetch a remote actor's inbox URL (for delivery). null on any failure OR
438+ * when the actor URL fails the SSRF gate (private/loopback/link-local),
439+ * which is how a Follow with `actor: http://169.254.169.254/…` is refused.
440+ * The response body is consumed on every path (no socket leak).
441+ */
264442 async function fetchActorInbox ( actorUrl ) {
443+ const res = await gatedFetch ( actorUrl , { headers : { accept : AP_CT } } , cfg , { follow : true } ) ;
444+ if ( ! res ) return null ;
445+ if ( ! res . ok ) { await res . body ?. cancel ( ) . catch ( ( ) => { } ) ; return null ; }
265446 try {
266- const res = await fetch ( String ( actorUrl ) . replace ( / # .* $ / , '' ) , {
267- headers : { accept : AP_CT } , redirect : 'follow' ,
268- } ) ;
269- if ( ! res . ok ) return null ;
270- const doc = await res . json ( ) ;
447+ const doc = await res . json ( ) ; // consumes the body
271448 return doc . inbox || doc . endpoints ?. sharedInbox || null ;
272- } catch { return null ; }
449+ } catch {
450+ await res . body ?. cancel ( ) . catch ( ( ) => { } ) ;
451+ return null ;
452+ }
273453 }
274454
275455 // ================================================================= routes
@@ -370,9 +550,14 @@ export async function activate(api) {
370550
371551 // Phase 1: storing needs no crypto. Inbound HTTP Signature VERIFICATION
372552 // (fetch the sender's actor key, verify the signature) is the Phase-2
373- // boundary — see README. We persist every activity to the inbox log.
553+ // boundary — see README. The inbox therefore remains UNAUTHENTICATED BY
554+ // DESIGN in Phase 1; the SSRF gate on outbound delivery (fetchActorInbox /
555+ // signAndDeliver) plus the caps below (maxInbox trim, follower dedupe) are
556+ // the mitigation for the abuse surface that opens. We persist every
557+ // activity to the inbox log, bounded to the most recent cfg.maxInbox.
374558 const state = loadState ( user ) ;
375559 state . inbox . push ( { receivedAt : new Date ( ) . toISOString ( ) , activity } ) ;
560+ if ( state . inbox . length > cfg . maxInbox ) state . inbox = state . inbox . slice ( - cfg . maxInbox ) ;
376561
377562 if ( activity . type === 'Follow' ) {
378563 const follower = typeof activity . actor === 'string' ? activity . actor : activity . actor ?. id ;
0 commit comments