Changeset 3dd99d3 in Klonkt


Ignore:
Timestamp:
06/25/2026 09:52:25 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
c648b04
Parents:
4b5223f
Message:

harden(fediverse): SSRF guard, remote-URL XSS scheme-guard, scoped Delete, gate-by-default + queue fixes

From a 3-agent hardening review of this session's fediverse code:

  • SSRF: all outbound fetches (deliver/fetchActor/webfingerResolve) now go through safeFetch — http(s)-only, rejects hosts resolving to private/loopback/link-local ranges on the initial host AND every redirect hop (redirect:manual), + actor-doc size cap. Blocks inbox-driven SSRF to cloud-metadata/internal services.
  • Stored XSS: remote actor url/icon, timeline media + author urls, and remote-note images/object_uri are now run through an http(s) scheme-guard before storage, so a malicious actor can't smuggle javascript:/data: into owner-only-rendered href/src.
  • Cross-actor Delete: inbound Delete is now scoped to the signing actor (can't wipe another actor's replies/timeline rows).
  • Gate-by-default: Add/Remove/Update added to the signature-enforced activity list.
  • Delivery queue: re-entrancy guard (30 rows x 8s can exceed the 60s tick -> no double-delivery) + backoff off-by-one fix (1-min first retry no longer skipped).
  • Scheduler: delete-before-insert on FTS so a re-flipped post has no duplicate row.
  • /meldingen: don't mark-seen for a viewer (GET-side mutation the global guard misses).
  • Activity ids get a random suffix to avoid same-millisecond collisions.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/posts.js

    r4b5223f r3dd99d3  
    77import ejs from 'ejs';
    88import db from '../config/database.js';
    9 import { requireAuth, requireSiteManager } from '../middleware/auth.js';
     9import { requireAuth, requireSiteManager, isViewer } from '../middleware/auth.js';
    1010import { renderPage } from '../middleware/render.js';
    1111import { recordPageview, recordPostView } from '../services/StatsService.js';
     
    591591  const site = res.locals.site;
    592592  const items = site ? ActivityPubService.getNotifications(site.slug, 80) : [];
    593   if (site) ActivityPubService.markNotificationsSeen(site.slug); // viewing = seen → clears the bell badge
     593  // viewing = seen → clears the bell badge. A viewer (kijker) may look but must not
     594  // mutate state (the global write-guard only catches non-GET, not this GET-side effect).
     595  if (site && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
    594596  renderPage(req, res, 'pages/fedi-notifications', { pageTitle: 'Meldingen', bodyClass: 'on-special', items });
    595597});
  • src/services/ActivityPubService.js

    r4b5223f r3dd99d3  
    1717 */
    1818import crypto from 'crypto';
     19import dns from 'dns';
     20import net from 'net';
    1921import db from '../config/database.js';
    2022import HtmlSanitizerService from './HtmlSanitizerService.js';
    2123
    2224const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
     25
     26// Short random suffix so two activity ids minted in the same millisecond (e.g.
     27// parallel saves) don't collide and get deduped by a receiver.
     28const rid = () => crypto.randomBytes(4).toString('hex');
     29
     30// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
     31// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
     32const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
     33
     34// ── SSRF guard for outbound fetches ───────────────────────────────
     35// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
     36// every outbound fetch must refuse hosts that resolve to private/loopback ranges
     37// (cloud metadata, internal services) — on the initial host AND each redirect hop.
     38function isBlockedIp(ip) {
     39  if (!ip) return true;
     40  const v = net.isIP(ip);
     41  if (v === 4) {
     42    const o = ip.split('.').map(Number);
     43    return o[0] === 127 || o[0] === 10 || o[0] === 0
     44      || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
     45      || (o[0] === 192 && o[1] === 168)
     46      || (o[0] === 169 && o[1] === 254)
     47      || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
     48  }
     49  if (v === 6) {
     50    const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
     51    return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
     52      || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
     53      || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
     54  }
     55  return true; // not an IP literal we recognise → refuse
     56}
     57async function assertPublicHost(hostname) {
     58  if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
     59  const addrs = await dns.promises.lookup(hostname, { all: true });
     60  if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
     61}
     62async function safeFetch(url, opts = {}, maxRedirects = 3) {
     63  let target = url;
     64  for (let hop = 0; ; hop++) {
     65    const u = new URL(target); // throws on malformed → caller's catch
     66    if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
     67    await assertPublicHost(u.hostname);
     68    const r = await fetch(target, { ...opts, redirect: 'manual', signal: AbortSignal.timeout(8000) });
     69    const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
     70    if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
     71    return r;
     72  }
     73}
    2374const MAX_OUTBOX = 20;
    2475// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
     
    323374    name: (doc && (doc.name || doc.preferredUsername)) || handle,
    324375    handle,
    325     url: (doc && (doc.url || doc.id)) || actorUri,
    326     icon: icon || null,
     376    url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
     377    icon: safeUrl(icon) || null,
    327378  };
    328379}
     
    406457  const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
    407458  const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
    408   const r = await fetch(inboxUrl, {
     459  const r = await safeFetch(inboxUrl, {
    409460    method: 'POST',
    410461    headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
    411462    body,
    412     signal: AbortSignal.timeout(8000),
    413463  });
    414464  return r.status;
     
    417467export async function fetchActor(url) {
    418468  try {
    419     const r = await fetch(url, { headers: { Accept: 'application/activity+json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
     469    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
    420470    if (!r.ok) return null;
     471    const len = Number(r.headers.get('content-length') || 0);
     472    if (len > 2_000_000) return null; // refuse oversized actor docs
    421473    return await r.json();
    422474  } catch { return null; }
     
    450502  enqueueDelivery(slug, inbox, activity);
    451503}
     504let _processingDeliv = false;
    452505export async function processDeliveryQueue() {
    453   let rows;
    454   try { rows = deliveryStmts().due.all(); } catch { return; }
    455   if (!rows || !rows.length) return;
    456   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    457   for (const row of rows) {
    458     let ok = false;
    459     try {
    460       const keys = getOrCreateKeys(row.slug);
    461       const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
    462       ok = st >= 200 && st < 300;
    463     } catch { ok = false; }
    464     if (ok) { deliveryStmts().del.run(row.id); continue; }
    465     const attempts = row.attempts + 1;
    466     if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
    467     const mins = DELIVERY_BACKOFF_MIN[Math.min(attempts, DELIVERY_BACKOFF_MIN.length - 1)];
    468     deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
    469   }
     506  if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
     507  _processingDeliv = true;
     508  try {
     509    let rows;
     510    try { rows = deliveryStmts().due.all(); } catch { return; }
     511    if (!rows || !rows.length) return;
     512    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     513    for (const row of rows) {
     514      let ok = false;
     515      try {
     516        const keys = getOrCreateKeys(row.slug);
     517        const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
     518        ok = st >= 200 && st < 300;
     519      } catch { ok = false; }
     520      if (ok) { deliveryStmts().del.run(row.id); continue; }
     521      const attempts = row.attempts + 1;
     522      if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
     523      // Index the backoff on the CURRENT attempt count (row.attempts) so the first
     524      // retry uses the 1-min tier instead of skipping it.
     525      const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
     526      deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
     527    }
     528  } finally { _processingDeliv = false; }
    470529}
    471530let _delivTimer = null;
     
    512571  // Blocked actor/domain → silently drop (202, don't reveal the block).
    513572  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor); return 202; }
    514   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject'];
     573  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update'];
    515574  if (GATED.includes(type)) {
    516575    if (!verified || !claimedActor || verified.id !== claimedActor) {
     
    529588    const me = actorId(base, slug);
    530589    const keys = getOrCreateKeys(slug);
    531     const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}`, type: 'Accept', actor: me, object: act };
     590    const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
    532591    deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
    533592    // Auto-backfill: send the new follower our recent posts as Create so their
     
    577636        const ai = actorInfo(await resolveActor(actorUri), actorUri);
    578637        const html = HtmlSanitizerService.sanitize(o.content || '');
    579         const media = JSON.stringify((Array.isArray(o.attachment) ? o.attachment : []).filter((a) => a && a.url).map((a) => ({ url: a.url, type: a.mediaType || '' })));
     638        const media = JSON.stringify((Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url));
    580639        for (const s of subs) tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media);
    581640        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
     
    596655  if (type === 'Delete') {
    597656    // A remote note was deleted upstream → drop it from replies AND the timeline.
     657    // Scope to the SIGNING actor so actor B can't delete actor A's content (the
     658    // signature gate guarantees claimedActor == the verified signer here).
    598659    const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
    599     if (oid) { iStmts().delReply.run(oid); try { tlStmts().del.run(oid); } catch { /* ignore */ } }
     660    if (oid && claimedActor) {
     661      try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
     662      try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
     663    }
    600664    return 202;
    601665  }
     
    665729  const del = {
    666730    '@context': 'https://www.w3.org/ns/activitystreams',
    667     id: `${nid}#delete-${Date.now()}`,
     731    id: `${nid}#delete-${Date.now()}-${rid()}`,
    668732    type: 'Delete',
    669733    actor: me,
     
    688752  const update = {
    689753    '@context': 'https://www.w3.org/ns/activitystreams',
    690     id: `${noteId(base, post.id)}#update-${Date.now()}`,
     754    id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
    691755    type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
    692756    object: note,
     
    708772  const update = {
    709773    '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
    710     id: `${me}#update-${Date.now()}`,
     774    id: `${me}#update-${Date.now()}-${rid()}`,
    711775    type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
    712776    object: buildActor(base, site),
     
    741805  // 1. Remove every current pin so Mastodon can recreate them in order.
    742806  for (const id of removeIds) {
    743     const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
     807    const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
    744808    for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
    745809  }
     
    748812  // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
    749813  for (const p of pinned) {
    750     const add = { '@context': AS, id: `${me}#add-${p.id}-${Date.now()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
     814    const add = { '@context': AS, id: `${me}#add-${p.id}-${Date.now()}-${rid()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
    751815    for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
    752816    await new Promise((r) => setTimeout(r, 2000));
     
    865929  const images = (Array.isArray(note.attachment) ? note.attachment : [])
    866930    .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
    867     .map((a) => a.url);
     931    .map((a) => safeUrl(a.url)).filter(Boolean);
    868932  return {
    869     object_uri: note.id,
     933    object_uri: safeUrl(note.id) || note.id,
    870934    actor_uri: actorUri,
    871935    actor_url: ai.url,
     
    895959    const me = actorId(base, site.slug);
    896960    const nid = noteId(base, row.id);
    897     const del = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nid}#delete-${Date.now()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
     961    const del = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
    898962    const keys = getOrCreateKeys(site.slug);
    899963    const inboxes = new Set();
     
    914978  const acct = `${parts[0]}@${parts[1]}`;
    915979  try {
    916     const r = await fetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
    917       { headers: { Accept: 'application/jrd+json, application/json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
     980    const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
     981      { headers: { Accept: 'application/jrd+json, application/json' } });
    918982    if (!r.ok) return null;
    919983    const jrd = await r.json();
    920984    const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
    921     return link ? link.href : null;
     985    return safeUrl(link ? link.href : '') || null;
    922986  } catch { return null; }
    923987}
     
    9581022  const me = actorId(base, site.slug);
    9591023  const keys = getOrCreateKeys(site.slug);
    960   const followId = `${me}#follow-${Date.now()}`;
     1024  const followId = `${me}#follow-${Date.now()}-${rid()}`;
    9611025  fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending');
    9621026  const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
     
    9731037  const row = fwStmts().one.get(site.slug, actorUri);
    9741038  if (row && row.inbox) {
    975     const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } };
     1039    const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } };
    9761040    try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
    9771041  }
     
    9891053  const act = {
    9901054    '@context': 'https://www.w3.org/ns/activitystreams',
    991     id: `${me}#${type.toLowerCase()}-${Date.now()}`,
     1055    id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`,
    9921056    type, actor: me, object: targetNoteId,
    9931057  };
  • src/services/Scheduler.js

    r4b5223f r3dd99d3  
    2424      "UPDATE posts SET status = 'published', published_at = COALESCE(published_at, publish_at, CURRENT_TIMESTAMP) WHERE id = ?"
    2525    );
     26    const ftsDel = db.prepare('DELETE FROM posts_fts WHERE post_id = ?');
    2627    const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)');
    2728    const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?');
    2829    for (const p of due) {
    2930      upd.run(p.id);
    30       try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
     31      // Delete-before-insert so a re-scheduled (previously published) post doesn't
     32      // get a duplicate FTS row → duplicate search hits.
     33      try { ftsDel.run(p.id); fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
    3134      // ActivityPub: federate the now-published post to followers.
    3235      if (!p.fan_only) {
Note: See TracChangeset for help on using the changeset viewer.