Changeset c55fb34 in Klonkt


Ignore:
Timestamp:
07/01/2026 01:03:39 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
cf314b4
Parents:
f0807cc
Message:

fix(federation): HTTP-sig replay window + mandatory digest (lqx); block boost-origin + exact-host purge (4lq)

lqx — verifyRequest now requires the Date header to be SIGNED and recent
(default +/-60min, env AP_SIG_MAX_SKEW_MIN), and requires a SIGNED Digest on
any request carrying a body. Without these a captured signed request could be
replayed indefinitely, or its body swapped (the body was uncovered by the
signature when Digest wasn't in the signed header set).

4lq — the Announce (boost) handler now drops a boost whose ORIGINAL author is
blocked (isBlockedAny(origUri)), closing a block bypass via someone else's
boost; and purgeBlocked matches a blocked domain by exact parsed host (same as
isBlockedAny) instead of a URL LIKE that missed bare-domain/:port actor URIs.

  • src/services/ActivityPubService.js — verifyRequest (date window + mandatory digest), Announce block-origin guard, purgeBlocked exact-host

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

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • .beads/issues.jsonl

    rf0807cc rc55fb34  
    1212{"_type":"issue","id":"prutfolio-src-7cz","title":"Discovery: how Klonkt users find each other (klonkt.com hub + discoverable + FoF)","description":"Turn klonkt.com from a static salespage into the network's front door WITHOUT\nbecoming a content host. Decided direction: Form B (a map/lens over the network),\nNOT Form A (a multi-user signup server — that takes on the host/moderation/liability\nrole we deliberately avoided; that path is managed hosting, a separate business\ndecision, not this).\n\n## What\n- A `discoverable` per-site toggle (Beheer) that sets `discoverable: true/false` on\n  the site's ActivityPub actor (AS2/Mastodon-standard property, already declared in\n  AP_CONTEXT). Opt-in = clean GDPR consent basis. Honored locally by other instances.\n- Phase 1: opt-in artist directory on klonkt.com — pointers only (avatar, name, genre,\n  link). No accounts, no hosted content.\n- Phase 2: network feed — a Klonkt \"network\" actor that FOLLOWS the opt-in artists;\n  their public posts land in its ap_timeline and render as a browsable \"what's\n  happening on Klonkt\" feed. Reuses existing AP machinery (inbox, follow, timeline\n  cache, Delete/Tombstone handling, offline-instance handling) — little new code.\n- Optional: an AP relay so new instances can plug into the public firehose (cold-start),\n  and/or a single-user showcase Klonkt instance next to the salespage as a live demo.\n\n## Why (discovery)\nSingle-user-per-server (our model) has no local timeline, so discovery is inherently\nharder than Mastodon. Layered approach: (0) plain fediverse, (1) friend-of-a-friend\nover the Cirkel graph, (2) auto-detect fellow Klonkt instances via NodeInfo, (3)\ngenre/hashtag browse, (4) this central opt-in on-ramp for cold-start. This issue = layer 4.\n\n## Invariants\n- No auto-follow / auto-boost — discovery = suggestions the user clicks, never an\n  automatic outward action (the no-auto-fediverse rule).\n- Gracefully degrade — a lonely instance still works; discovery is a plus.\n- Opt-in per site (`discoverable`), honored locally; not-discoverable != private\n  (an AP actor is always followable if you know the handle).\n- Scale-safe: cache NodeInfo, use the SSRF guard (safeFetch), prune.\n\n## Maintenance / GDPR (assessed light)\n- Tech: one more pm2 service in the klonkt-stats pattern; reuses the AP stack. Low.\n- GDPR: only public, self-published, opt-in content; we are a cache/lens, not the\n  origin; federated Delete/Tombstone already drops cached posts (erasure largely\n  automatic). Need: a delist/opt-out path, data minimization + pruning, a\n  privacy-policy paragraph (klonkt.com/privacy exists), a takedown contact\n  (info@robingenis.com). Materially lighter than Form A.\n- Moderation: delist a spammy instance = drop the follow (remove a pointer), reversible.\n\n## Suggested order\n1. `discoverable` toggle (Beheer -\u003e actor). Cheap, standalone.\n2. \"Discover: neighbours of your Cirkel\" strip on /cirkel (layer 1, friend-of-a-friend)\n   that honors `discoverable`. Most \"aha\" per effort, fully decentralized, no klonkt.com.\n3. klonkt.com opt-in directory (pointers-only).\n4. klonkt.com network feed (network actor follows opt-in artists) + optional relay.\n","status":"open","priority":3,"issue_type":"feature","owner":"roboburr@gmail.com","created_at":"2026-07-01T00:34:33Z","created_by":"roboburr","updated_at":"2026-07-01T00:34:33Z","labels":["discovery","fediverse"],"dependency_count":0,"dependent_count":0,"comment_count":0}
    1313{"_type":"issue","id":"prutfolio-src-4fk","title":"Fediverse: serialize pin-resync per site (parallelle /save race)","description":"Hardening-review: twee gelijktijdige /save-calls die pin-rank wijzigen draaien beide resyncFeaturedPins (Remove-all -\u003e 5s -\u003e Add) interleaved -\u003e niet-deterministische StatusPin-volgorde op Mastodon. Serialiseer per slug (in-process Map\u003cslug,Promise\u003e-chain) of snapshot de pins onder de commit-transactie.","status":"closed","priority":3,"issue_type":"task","assignee":"roboburr","owner":"roboburr@gmail.com","created_at":"2026-06-25T09:53:21Z","created_by":"roboburr","updated_at":"2026-07-01T00:57:35Z","started_at":"2026-07-01T00:55:28Z","closed_at":"2026-07-01T00:57:35Z","close_reason":"Fixed: resyncFeaturedPins() is now a per-site serializing+coalescing wrapper around doResyncFeaturedPins(); concurrent /save calls can no longer interleave the Remove/Add sequences (which scrambled StatusPin order), and rapid saves coalesce into one rerun instead of N.","dependency_count":0,"dependent_count":0,"comment_count":0}
    14 {"_type":"issue","id":"prutfolio-src-4lq","title":"Fediverse: block-handling completeness follow-up","description":"Hardening follow-up. Specifics are intentionally kept in the maintainer's private notes, not in this public tracker. Priority medium/low; the critical fediverse issues (SSRF, stored-XSS, cross-actor delete) are already fixed.","status":"open","priority":3,"issue_type":"task","owner":"roboburr@gmail.com","created_at":"2026-06-25T09:53:19Z","created_by":"roboburr","updated_at":"2026-06-25T19:29:08Z","dependency_count":0,"dependent_count":0,"comment_count":0}
    15 {"_type":"issue","id":"prutfolio-src-lqx","title":"Fediverse: HTTP-signature hardening follow-up","description":"Hardening follow-up. Specifics are intentionally kept in the maintainer's private notes, not in this public tracker. Priority medium/low; the critical fediverse issues (SSRF, stored-XSS, cross-actor delete) are already fixed.","status":"open","priority":3,"issue_type":"task","owner":"roboburr@gmail.com","created_at":"2026-06-25T09:53:18Z","created_by":"roboburr","updated_at":"2026-06-25T19:29:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
     14{"_type":"issue","id":"prutfolio-src-4lq","title":"Fediverse: block-handling completeness follow-up","description":"Hardening follow-up. Specifics are intentionally kept in the maintainer's private notes, not in this public tracker. Priority medium/low; the critical fediverse issues (SSRF, stored-XSS, cross-actor delete) are already fixed.","status":"in_progress","priority":3,"issue_type":"task","assignee":"roboburr","owner":"roboburr@gmail.com","created_at":"2026-06-25T09:53:19Z","created_by":"roboburr","updated_at":"2026-07-01T01:01:49Z","started_at":"2026-07-01T01:01:49Z","dependency_count":0,"dependent_count":0,"comment_count":0}
     15{"_type":"issue","id":"prutfolio-src-lqx","title":"Fediverse: HTTP-signature hardening follow-up","description":"Hardening follow-up. Specifics are intentionally kept in the maintainer's private notes, not in this public tracker. Priority medium/low; the critical fediverse issues (SSRF, stored-XSS, cross-actor delete) are already fixed.","status":"in_progress","priority":3,"issue_type":"task","assignee":"roboburr","owner":"roboburr@gmail.com","created_at":"2026-06-25T09:53:18Z","created_by":"roboburr","updated_at":"2026-07-01T01:01:47Z","started_at":"2026-07-01T01:01:47Z","dependency_count":0,"dependent_count":0,"comment_count":0}
    1616{"_type":"issue","id":"prutfolio-src-e44","title":"Asset cache-busting via commit-hash ipv handmatig bumpen","description":"Nu staan ?v=N hardcoded in shell.ejs. Beter: middleware zet res.locals.assetVersion = git-commit-sha (build-time of runtime) zodat elke deploy auto-cache-bust krijgt. Behandel alle static script/css tags in shell.ejs (htmx.min.js, audio-player.js, install-app.js).","status":"open","priority":3,"issue_type":"task","owner":"robin@roboburr.com","created_at":"2026-05-07T20:03:17Z","created_by":"Robin","updated_at":"2026-05-07T20:03:17Z","dependency_count":0,"dependent_count":0,"comment_count":0}
  • src/services/ActivityPubService.js

    rf0807cc rc55fb34  
    717717// Best-effort verification of an incoming signed request. Returns the sender's
    718718// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     719// Max clock skew for the signed Date header (replay window). Generous default to tolerate
     720// federating servers with drifting clocks; an operator can widen it via env.
     721const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
    719722export async function verifyRequest(req) {
    720723  const sigH = req.headers['signature'];
     
    745748    try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
    746749  }
    747   if (ok && hs.includes('digest') && req.rawBody) {
    748     const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
    749     if (req.headers['digest'] !== exp) ok = false;
     750  // Replay defence: the Date header must be signed and recent. A captured signed request
     751  // replayed later (or with a swapped body) is rejected.
     752  if (ok) {
     753    if (!hs.includes('date')) ok = false;
     754    else {
     755      const t = Date.parse(req.headers['date'] || '');
     756      if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
     757    }
     758  }
     759  // Digest is MANDATORY when the request carries a body: without a signed digest the body
     760  // isn't covered by the signature and could be swapped on a replay.
     761  if (ok && req.rawBody && req.rawBody.length) {
     762    if (!hs.includes('digest')) ok = false;
     763    else {
     764      const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
     765      if (req.headers['digest'] !== exp) ok = false;
     766    }
    750767  }
    751768  return ok ? actor : null;
     
    903920        if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
    904921          const origUri = actorUriOf(bn.attributedTo);
     922          // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
     923          // author is blocked — otherwise a block is bypassed via someone else's boost.
     924          if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
    905925          const oai = actorInfo(await resolveActor(origUri), origUri);
    906926          const html = HtmlSanitizerService.sanitize(bn.content || '');
     
    18351855  try {
    18361856    if (kind === 'domain') {
    1837       const like = `%//${target}/%`;
    1838       db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
    1839       db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
    1840       db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
     1857      // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
     1858      // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
     1859      const purge = (table, col) => {
     1860        let rows = [];
     1861        try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
     1862        const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
     1863        for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } }
     1864      };
     1865      purge('ap_interactions', 'actor_uri');
     1866      purge('ap_timeline', 'author_uri');
     1867      purge('ap_followers', 'actor_uri');
    18411868    } else {
    18421869      db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
Note: See TracChangeset for help on using the changeset viewer.