Changeset 3dd99d3 in Klonkt
- Timestamp:
- 06/25/2026 09:52:25 AM (3 months ago)
- Branches:
- main
- Children:
- c648b04
- Parents:
- 4b5223f
- Location:
- src
- Files:
-
- 3 edited
-
routes/posts.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (20 diffs)
-
services/Scheduler.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/posts.js
r4b5223f r3dd99d3 7 7 import ejs from 'ejs'; 8 8 import db from '../config/database.js'; 9 import { requireAuth, requireSiteManager } from '../middleware/auth.js';9 import { requireAuth, requireSiteManager, isViewer } from '../middleware/auth.js'; 10 10 import { renderPage } from '../middleware/render.js'; 11 11 import { recordPageview, recordPostView } from '../services/StatsService.js'; … … 591 591 const site = res.locals.site; 592 592 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); 594 596 renderPage(req, res, 'pages/fedi-notifications', { pageTitle: 'Meldingen', bodyClass: 'on-special', items }); 595 597 }); -
src/services/ActivityPubService.js
r4b5223f r3dd99d3 17 17 */ 18 18 import crypto from 'crypto'; 19 import dns from 'dns'; 20 import net from 'net'; 19 21 import db from '../config/database.js'; 20 22 import HtmlSanitizerService from './HtmlSanitizerService.js'; 21 23 22 24 const 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. 28 const 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). 32 const 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. 38 function 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 } 57 async 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 } 62 async 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 } 23 74 const MAX_OUTBOX = 20; 24 75 // Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH … … 323 374 name: (doc && (doc.name || doc.preferredUsername)) || handle, 324 375 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, 327 378 }; 328 379 } … … 406 457 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64'); 407 458 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, { 409 460 method: 'POST', 410 461 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig }, 411 462 body, 412 signal: AbortSignal.timeout(8000),413 463 }); 414 464 return r.status; … … 417 467 export async function fetchActor(url) { 418 468 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' } }); 420 470 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 421 473 return await r.json(); 422 474 } catch { return null; } … … 450 502 enqueueDelivery(slug, inbox, activity); 451 503 } 504 let _processingDeliv = false; 452 505 export 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; } 470 529 } 471 530 let _delivTimer = null; … … 512 571 // Blocked actor/domain → silently drop (202, don't reveal the block). 513 572 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']; 515 574 if (GATED.includes(type)) { 516 575 if (!verified || !claimedActor || verified.id !== claimedActor) { … … 529 588 const me = actorId(base, slug); 530 589 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 }; 532 591 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message)); 533 592 // Auto-backfill: send the new follower our recent posts as Create so their … … 577 636 const ai = actorInfo(await resolveActor(actorUri), actorUri); 578 637 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)); 580 639 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); 581 640 console.log('[AP] timeline +', actorUri, 'x' + subs.length); … … 596 655 if (type === 'Delete') { 597 656 // 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). 598 659 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 } 600 664 return 202; 601 665 } … … 665 729 const del = { 666 730 '@context': 'https://www.w3.org/ns/activitystreams', 667 id: `${nid}#delete-${Date.now()} `,731 id: `${nid}#delete-${Date.now()}-${rid()}`, 668 732 type: 'Delete', 669 733 actor: me, … … 688 752 const update = { 689 753 '@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()}`, 691 755 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`], 692 756 object: note, … … 708 772 const update = { 709 773 '@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()}`, 711 775 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`], 712 776 object: buildActor(base, site), … … 741 805 // 1. Remove every current pin so Mastodon can recreate them in order. 742 806 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] }; 744 808 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ }); 745 809 } … … 748 812 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at. 749 813 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`] }; 751 815 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ }); 752 816 await new Promise((r) => setTimeout(r, 2000)); … … 865 929 const images = (Array.isArray(note.attachment) ? note.attachment : []) 866 930 .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); 868 932 return { 869 object_uri: note.id,933 object_uri: safeUrl(note.id) || note.id, 870 934 actor_uri: actorUri, 871 935 actor_url: ai.url, … … 895 959 const me = actorId(base, site.slug); 896 960 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' } }; 898 962 const keys = getOrCreateKeys(site.slug); 899 963 const inboxes = new Set(); … … 914 978 const acct = `${parts[0]}@${parts[1]}`; 915 979 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' } }); 918 982 if (!r.ok) return null; 919 983 const jrd = await r.json(); 920 984 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; 922 986 } catch { return null; } 923 987 } … … 958 1022 const me = actorId(base, site.slug); 959 1023 const keys = getOrCreateKeys(site.slug); 960 const followId = `${me}#follow-${Date.now()} `;1024 const followId = `${me}#follow-${Date.now()}-${rid()}`; 961 1025 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending'); 962 1026 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id }; … … 973 1037 const row = fwStmts().one.get(site.slug, actorUri); 974 1038 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 } }; 976 1040 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } 977 1041 } … … 989 1053 const act = { 990 1054 '@context': 'https://www.w3.org/ns/activitystreams', 991 id: `${me}#${type.toLowerCase()}-${Date.now()} `,1055 id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`, 992 1056 type, actor: me, object: targetNoteId, 993 1057 }; -
src/services/Scheduler.js
r4b5223f r3dd99d3 24 24 "UPDATE posts SET status = 'published', published_at = COALESCE(published_at, publish_at, CURRENT_TIMESTAMP) WHERE id = ?" 25 25 ); 26 const ftsDel = db.prepare('DELETE FROM posts_fts WHERE post_id = ?'); 26 27 const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'); 27 28 const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?'); 28 29 for (const p of due) { 29 30 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 */ } 31 34 // ActivityPub: federate the now-published post to followers. 32 35 if (!p.fan_only) {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)