Skip to content

Commit 03e4db5

Browse files
activitypub: harden the unauthenticated inbox (SSRF + DoS)
Audit finding (top severity): the Phase-1 inbox verifies no signature and fetched an attacker-controlled actor/inbox URL with redirect:follow and no gate — POST /ap/alice/inbox {type:Follow,actor:http://169.254. 169.254/...} pivoted requests from the server's network position, and every unauth POST grew per-user state unboundedly with a non-atomic write. Fixes: (A) gatedFetch ports corsproxy's IPv4/IPv6 private-address guard + dns resolvesToPublic (fails closed), scheme-checks, and follows redirects MANUALLY re-validating every hop; fetchActorInbox and the signAndDeliver POST both route through it. (B) inbox trimmed to config.maxInbox (500), followers deduped by actor id. (C) saveState + keypair write are now atomic (temp+rename). (D) response bodies consumed on all paths. (E) listNotes outbox walk capped at config.maxResources. Default rejects private targets (allowPrivateDelivery:false); test proves a loopback-actor Follow triggers zero delivery. Inbound signature verify remains Phase 2 (documented); residual: DNS-rebind TOCTOU, same as corsproxy.
1 parent f1255a7 commit 03e4db5

3 files changed

Lines changed: 320 additions & 15 deletions

File tree

activitypub/README.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ operator must widen WAC: `createServer({ appPaths: ['/ap'], … })`. See
3737
| `GET` | `/ap/<user>/actor` | ✅ done | AS2 `Person` + `publicKey.publicKeyPem` (RSA, per-actor, persisted) |
3838
| `GET` | `/ap/<user>/outbox` | ✅ done | `OrderedCollection`; `?page=true``OrderedCollectionPage` with `Create{Note}` items |
3939
| `POST` | `/ap/<user>/outbox` | ✅ done | owner-only (`api.auth.getAgent`); stores a Note in the pod via loopback PUT |
40-
| `POST` | `/ap/<user>/inbox` | ✅ done (store) | persists every activity; `Follow` records the follower; **inbound signature verify: Phase 2** |
40+
| `POST` | `/ap/<user>/inbox` | ✅ done (store) | persists every activity (bounded `maxInbox`); `Follow` records the follower (deduped); **inbound signature verify: Phase 2** — unauthenticated-by-design, so outbound delivery is SSRF-gated |
4141
| `GET` | `/ap/<user>/followers` | ✅ done | `OrderedCollection` from persisted state |
4242
| `GET` | `/ap/<user>/following` | ✅ done | `OrderedCollection` (empty in Phase 1 — no outbound Follow yet) |
4343
|| outbound delivery signing | ✅ done (stretch) | `signAndDeliver` signs POSTs with the actor RSA key (draft-cavage); `Accept` to a follower, `Create` to followers on post — best-effort, non-blocking |
@@ -100,7 +100,37 @@ Signature.
100100
`node:crypto`) — core's own inbox even logs-but-doesn't-reject today — but
101101
it's deferred here: Phase 1 **stores** inbound activities unconditionally
102102
and documents the gap. No core seam is required to close it; it's scope, not
103-
a wall.
103+
a wall. **The inbox therefore remains unauthenticated-by-design in Phase 1.**
104+
105+
### Hardening the unauthenticated inbox (SSRF + DoS gates)
106+
107+
Because inbound signatures aren't verified yet, an anonymous `Follow` supplies
108+
an attacker-controlled `actor` URL that the server would otherwise fetch (to
109+
resolve a delivery inbox) and then POST to — a classic SSRF against cloud
110+
metadata / internal services — and an anonymous flood could grow the persisted
111+
state without bound. Those surfaces are now **gated** (the mitigation that
112+
stands in for signature verification until Phase 2):
113+
114+
- **SSRF gate on all outbound delivery** (`fetchActorInbox` + `signAndDeliver`).
115+
A port of `corsproxy/`'s guard: the target scheme must be `http`/`https`, the
116+
hostname is resolved (`node:dns`), and delivery is **refused if any resolved
117+
address is private/loopback/link-local (incl. `169.254.169.254`)/CGNAT/ULA/
118+
unspecified**, failing **closed** on a resolution error. Redirects are
119+
followed **manually** and re-validated per hop (never `redirect:'follow'`).
120+
Default is **closed**; `config.allowPrivateDelivery: true` is a documented
121+
escape hatch for local/loopback testing only.
122+
- **Bounded state.** The inbox log is trimmed to the most recent
123+
`config.maxInbox` (default 500) on every write, and the followers ledger is
124+
**deduped by actor id** (duplicate `Follow` spam can't grow it).
125+
- **Atomic persistence.** State and keypair writes go to a temp file then
126+
`fs.renameSync` over the target, so a crash mid-write can't truncate the
127+
followers ledger / keypair.
128+
- **Capped outbox walk.** `GET outbox` caps its loopback status-container walk
129+
at `config.maxResources` (default 1000), like `backup/` + `sparql/`.
130+
- **No leaked sockets.** Delivery responses are drained/cancelled on every path.
131+
132+
Config knobs (all default-safe): `allowPrivateDelivery` (default `false`),
133+
`maxInbox` (default `500`), `maxResources` (default `1000`).
104134

105135
## Findings
106136

activitypub/plugin.js

Lines changed: 197 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,137 @@
4545
import crypto from 'node:crypto';
4646
import fs from 'node:fs';
4747
import 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

49180
const AS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
50181
const 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]+)\.jsonld$/.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

Comments
 (0)