source: Klonkt/test/forwarded-activities.test.js@ 5296349

main
Last change on this file since 5296349 was 5296349, checked in by Claude (agent) <aiclaude@…>, 5 weeks ago

Doorgestuurde activiteiten alsnog verifiëren bij de bron (shaer-s8k)

De inbox eiste dat een activiteit was ondertekend door de actor die hij claimde
te zijn. Bij DOORSTUREN klopt dat niet: reageert iemand in een thread, dan stuurt
de server van de oorspronkelijke poster die reactie door en ondertekent met zijn
EIGEN sleutel. Op boiert.eu leverde dat tientallen geweigerde Creates op van
echte accounts -- reacties die niet aankwamen, zonder dat iemand een fout zag.

Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
JSON-LD-canonicalisatie; dit doet het lichter en strenger: bij een mismatch
geloven we de bezorgde inhoud NIET en halen we het object op bij de bron.

Vier voorwaarden, en geen ervan is optioneel:

  • alleen Create en Update. Een doorgestuurde Delete is niet te dereferencen (het object is weg) en blijft geweigerd.
  • de host van de object-id moet die van de geclaimde actor zijn. Zonder dit anker wijst een doorsturer je naar een host die hij zelf beheert, waar attributedTo alles kan beweren.
  • het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders levert een doorsturer een echt id met verdraaide inhoud.
  • mislukt het ophalen, wijkt het id af, of schrijft de bron het object niet toe aan de geclaimde actor, dan blijft het een weigering.

Een ongetekende activiteit wordt niet gedereferenced: zonder bewijs van wie het
bezorgde is er niets om op te bouwen. Beide fetch-paden lopen door safeFetch met
zijn SSRF-preflight.

Ook de logregel gesplitst. "(unsigned/invalid)" betekende drie dingen tegelijk;
het onderscheidt nu 'no signature', 'signature present, unverifiable' (meestal
een opgeheven account waarvan de sleutel weg is -- dat verklaart het gros van de
Delete-regels) en 'signer mismatch'.

Acht tests, waarvan zes weiger-gevallen: andere host, verkeerde attributedTo,
bron onbereikbaar, afwijkend id, doorgestuurde Delete, en ongetekend. Die
gebruiken IP-literals uit TEST-NET-3, want safeFetch doet dan geen DNS-lookup en
de test blijft offline.

Suite 449/449.

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

  • Property mode set to 100644
File size: 6.1 KB
Line 
1// Doorgestuurde activiteiten (shaer-s8k): een geldige handtekening van iemand
2// anders dan de auteur is doorsturen, geen vervalsing. We geloven de bezorgde
3// inhoud niet en halen het object bij de bron op.
4//
5// Deze tests dekken dereferenceForwarded via de echte handleInbox, met een
6// gestubde fetch: het gaat om de BESLISSING (accepteren of weigeren) en om wat
7// er wordt opgeslagen, niet om HTTP.
8//
9// Run: npm test
10
11import { test } from 'node:test';
12import assert from 'node:assert/strict';
13
14process.env.DATABASE_PATH = ':memory:';
15process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
16
17const dbMod = await import('../src/config/database.js');
18const db = dbMod.default;
19dbMod.initializeDatabase();
20const AP = await import('../src/services/ActivityPubService.js');
21
22db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
23 .run('u1', 'u1', 'u1@test', 'x', 'god');
24db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
25
26// IP-literals uit de documentatierange (TEST-NET-3): safeFetch doet dan GEEN
27// DNS-lookup (zie assertPublicHost) en ze staan niet in de geblokkeerde ranges,
28// dus de SSRF-preflight laat ze door en de gestubde fetch vangt het verzoek op.
29// Met verzonnen hostnamen faalde de preflight en kwam het nooit tot ophalen.
30const AUTEUR = 'https://203.0.113.10/users/anna';
31const DOORSTUURDER = 'https://203.0.113.20/users/relay';
32const NOTE_ID = 'https://203.0.113.10/notes/1';
33
34// Wat de bron teruggeeft als we het object ophalen. Per test aan te passen.
35let bron = null;
36let opgehaald = [];
37const echteFetch = globalThis.fetch;
38globalThis.fetch = async (url, opts) => {
39 const u = String(url);
40 opgehaald.push(u);
41 if (u === NOTE_ID && bron) {
42 return new Response(JSON.stringify(bron), { status: 200, headers: { 'content-type': 'application/activity+json' } });
43 }
44 return new Response('not found', { status: 404 });
45};
46
47/** Een doorgestuurde Create: ondertekend door de doorstuurder, geschreven door de auteur. */
48function doorgestuurd(objectOverride) {
49 return {
50 type: 'Create',
51 actor: AUTEUR,
52 object: objectOverride !== undefined ? objectOverride : {
53 id: NOTE_ID, type: 'Note', attributedTo: AUTEUR,
54 content: '<p>BEZORGDE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
55 },
56 };
57}
58const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
59const alsDoorstuurder = { id: DOORSTUURDER };
60
61test('doorgestuurde Create wordt geaccepteerd na ophalen bij de bron', async () => {
62 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
63 opgehaald = [];
64 const act = doorgestuurd();
65 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
66 assert.notEqual(status, 401, 'een doorgestuurde reactie hoort niet meer geweigerd te worden');
67 assert.ok(opgehaald.includes(NOTE_ID), 'het object hoort bij de bron opgehaald te zijn');
68 // Wat telt: de OPGEHAALDE inhoud wordt gebruikt, niet wat de doorstuurder gaf.
69 assert.match(act.object.content, /ECHTE versie/);
70 assert.doesNotMatch(act.object.content, /BEZORGDE/);
71});
72
73test('een doorstuurder die de inhoud verdraait wint daar niets mee', async () => {
74 // Hetzelfde echte id, maar de bezorgde payload liegt over de inhoud. De bron
75 // is de waarheid; de payload wordt weggegooid.
76 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
77 const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>KOOP MIJN MUNTEN</p>' });
78 await AP.handleInbox(req(act), 'me', alsDoorstuurder);
79 assert.doesNotMatch(act.object.content, /MUNTEN/);
80});
81
82test('object op een ANDERE host dan de geclaimde actor wordt geweigerd', async () => {
83 // Zonder deze ankereis wijst een doorsturer je naar een host die hij zelf
84 // beheert, waar attributedTo alles kan beweren.
85 bron = { id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
86 const act = doorgestuurd({ id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' });
87 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
88 assert.equal(status, 401);
89});
90
91test('de bron die het object NIET aan de geclaimde actor toeschrijft → geweigerd', async () => {
92 bron = { id: NOTE_ID, type: 'Note', attributedTo: 'https://203.0.113.10/users/iemandanders', content: '<p>x</p>' };
93 const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
94 assert.equal(status, 401);
95});
96
97test('bron onbereikbaar → geweigerd, geen twijfelgeval opgeslagen', async () => {
98 bron = null; // de stub geeft 404
99 const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
100 assert.equal(status, 401);
101});
102
103test('een doorgestuurde Delete blijft geweigerd', async () => {
104 // Niet te dereferencen: het object is per definitie weg.
105 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR };
106 const act = { type: 'Delete', actor: AUTEUR, object: NOTE_ID };
107 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
108 assert.equal(status, 401);
109});
110
111test('een ONGETEKENDE activiteit blijft geweigerd, ook met een geldig object', async () => {
112 // Zonder bewijs van wie het bezorgde is er niets om op te bouwen; dan mag er
113 // ook niet gedereferenced worden.
114 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
115 opgehaald = [];
116 const status = await AP.handleInbox({ body: doorgestuurd(), headers: {}, ip: '203.0.113.9' }, 'me', null);
117 assert.equal(status, 401);
118 assert.equal(opgehaald.includes(NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
119});
120
121test('een object-id dat naar iets anders omleidt wordt geweigerd', async () => {
122 // De bron geeft een ander id terug dan we opvroegen: dan weten we niet wat we
123 // in handen hebben.
124 bron = { id: 'https://203.0.113.10/notes/999', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
125 const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
126 assert.equal(status, 401);
127});
128
129test.after(() => { globalThis.fetch = echteFetch; });
Note: See TracBrowser for help on using the repository browser.