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

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

Dereference smaller, onbetekend eerst, en mislukkingen onthouden (shaer-drf)

De twee veiligheidsvervolgstappen die in de notities van twee GESLOTEN beads
stonden en daarmee onvindbaar waren. Eerst als eigen bead gezet, nu uitgevoerd.

  1. ALLEEN BIJ EEN OUDER DIE WE KENNEN. dereferenceForwarded haalde een object op zodra een geldig ondertekende activiteit een andere auteur claimde dan de ondertekenaar. Maar claimedActor en object.id zijn allebei door de aanvaller gekozen, en het host-anker eist alleen dat ze aan ELKAAR gelijk zijn -- dus kon iedereen met een werkende fediverse-actor ons naar elke https-URL sturen. Nu moet inReplyTo wijzen naar iets van ons: een eigen post, een eigen outbox-antwoord, een rij in ap_timeline of een bekende reactie in ap_interactions. Doorsturen bestaat juist omdát wij in de thread zitten, dus dat kost geen legitiem verkeer waarvan we de ouder kennen. Wat wel afvalt wordt apart gelogd (skipped: unknown inReplyTo), zodat zichtbaar is of de vernauwing echte inhoud kost.
  1. ONBETEKEND EERST. Die GET werd nog ondertekend namens ons. efe5633 haalde dat weg bij de sleutel-ophaal in fetchActor maar niet hier, dus de attributie-blootstelling die ik dacht te hebben weggenomen bestond op dit pad nog. Nu onbetekend, en alleen tekenen als dat niet volstaat.
  1. MISLUKKINGEN ONTHOUDEN. Mastodon herhaalt dagenlang; elke herhaling deed de fetch opnieuw. Een negatieve cache van een half uur, begrensd op 500 items.

Vier tests erbij. Een ervan viel eerst om doordat een eerdere test dezelfde note
had laten mislukken en de cache hem dus oversloeg -- dat was geen fout maar het
bewijs dat de cache werkt; de test heeft nu een eigen id, met die reden erbij.

Suite 458/458.

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

  • Property mode set to 100644
File size: 10.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({ url: u, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
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 note die WIJ kennen: doorsturen gebeurt omdat wij in de thread zitten, en
48// sinds shaer-drf dereferencen we alleen als inReplyTo daarheen wijst.
49const ONZE_NOTE = 'https://203.0.113.10/notes/van-ons';
50db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
51 VALUES (?,?,?,?,?,?)`).run(ONZE_NOTE, 'me', AUTEUR, 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
52
53/** Een doorgestuurde Create: ondertekend door de doorstuurder, geschreven door de auteur. */
54function doorgestuurd(objectOverride) {
55 return {
56 type: 'Create',
57 actor: AUTEUR,
58 object: objectOverride !== undefined ? objectOverride : {
59 id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE,
60 content: '<p>BEZORGDE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
61 },
62 };
63}
64const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
65const alsDoorstuurder = { id: DOORSTUURDER };
66
67test('doorgestuurde Create wordt geaccepteerd na ophalen bij de bron', async () => {
68 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
69 opgehaald = [];
70 const act = doorgestuurd();
71 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
72 assert.notEqual(status, 401, 'een doorgestuurde reactie hoort niet meer geweigerd te worden');
73 assert.ok(opgehaald.some((v) => v.url === NOTE_ID), 'het object hoort bij de bron opgehaald te zijn');
74 // Wat telt: de OPGEHAALDE inhoud wordt gebruikt, niet wat de doorstuurder gaf.
75 assert.match(act.object.content, /ECHTE versie/);
76 assert.doesNotMatch(act.object.content, /BEZORGDE/);
77});
78
79test('een doorstuurder die de inhoud verdraait wint daar niets mee', async () => {
80 // Hetzelfde echte id, maar de bezorgde payload liegt over de inhoud. De bron
81 // is de waarheid; de payload wordt weggegooid.
82 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
83 const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>KOOP MIJN MUNTEN</p>' });
84 await AP.handleInbox(req(act), 'me', alsDoorstuurder);
85 assert.doesNotMatch(act.object.content, /MUNTEN/);
86});
87
88test('object op een ANDERE host dan de geclaimde actor wordt geweigerd', async () => {
89 // Zonder deze ankereis wijst een doorsturer je naar een host die hij zelf
90 // beheert, waar attributedTo alles kan beweren.
91 bron = { id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' };
92 const act = doorgestuurd({ id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
93 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
94 assert.equal(status, 401);
95});
96
97test('de bron die het object NIET aan de geclaimde actor toeschrijft → geweigerd', async () => {
98 bron = { id: NOTE_ID, type: 'Note', attributedTo: 'https://203.0.113.10/users/iemandanders', content: '<p>x</p>' };
99 const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
100 assert.equal(status, 401);
101});
102
103test('bron onbereikbaar → geweigerd, geen twijfelgeval opgeslagen', async () => {
104 bron = null; // de stub geeft 404
105 const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
106 assert.equal(status, 401);
107});
108
109test('een doorgestuurde Delete blijft geweigerd', async () => {
110 // Niet te dereferencen: het object is per definitie weg.
111 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR };
112 const act = { type: 'Delete', actor: AUTEUR, object: NOTE_ID };
113 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
114 assert.equal(status, 401);
115});
116
117test('een ONGETEKENDE activiteit blijft geweigerd, ook met een geldig object', async () => {
118 // Zonder bewijs van wie het bezorgde is er niets om op te bouwen; dan mag er
119 // ook niet gedereferenced worden.
120 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
121 opgehaald = [];
122 const status = await AP.handleInbox({ body: doorgestuurd(), headers: {}, ip: '203.0.113.9' }, 'me', null);
123 assert.equal(status, 401);
124 assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
125});
126
127test('een object-id dat naar iets anders omleidt wordt geweigerd', async () => {
128 // De bron geeft een ander id terug dan we opvroegen: dan weten we niet wat we
129 // in handen hebben.
130 bron = { id: 'https://203.0.113.10/notes/999', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
131 const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
132 assert.equal(status, 401);
133});
134
135test('een antwoord op iets dat we NIET kennen wordt niet opgehaald', async () => {
136 // De vernauwing uit shaer-drf. Zonder deze eis zijn claimedActor en object.id
137 // allebei door de aanvaller gekozen en eist het host-anker alleen dat ze aan
138 // elkaar gelijk zijn -- dan kan iedereen met een werkende actor ons naar elke
139 // URL sturen.
140 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
141 opgehaald = [];
142 const act = doorgestuurd({
143 id: NOTE_ID, type: 'Note', attributedTo: AUTEUR,
144 inReplyTo: 'https://203.0.113.10/notes/kennen-we-niet', content: '<p>x</p>',
145 });
146 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
147 assert.equal(status, 401);
148 assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
149});
150
151test('zonder inReplyTo wordt er niets opgehaald', async () => {
152 bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
153 opgehaald = [];
154 const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' });
155 assert.equal(await AP.handleInbox(req(act), 'me', alsDoorstuurder), 401);
156 assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false);
157});
158
159test('de dereference haalt ONBETEKEND op als dat volstaat', async () => {
160 // Anders kan een ander ons een ondertekend verzoek naar een adres van zijn
161 // keuze laten sturen, met onze identiteit eronder.
162 //
163 // Eigen id: NOTE_ID is in een eerdere test mislukt en zit dus in de negatieve
164 // cache -- die zou deze poging overslaan. Dat de test daarop stukliep, is het
165 // bewijs dat de cache doet wat hij moet.
166 const VERS = 'https://203.0.113.10/notes/vers';
167 const stubOrig = globalThis.fetch;
168 globalThis.fetch = async (url, opts = {}) => {
169 if (String(url) === VERS) {
170 opgehaald.push({ url: VERS, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
171 return new Response(JSON.stringify({ id: VERS, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' }),
172 { status: 200, headers: { 'content-type': 'application/activity+json' } });
173 }
174 return stubOrig(url, opts);
175 };
176 opgehaald = [];
177 const act = doorgestuurd({ id: VERS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>y</p>' });
178 const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
179 globalThis.fetch = stubOrig;
180 assert.notEqual(status, 401);
181 const pogingen = opgehaald.filter((v) => v.url === VERS);
182 assert.equal(pogingen.length, 1, 'één poging');
183 assert.equal(pogingen[0].ondertekend, false, 'en die was onbetekend');
184});
185
186test('een mislukte poging wordt onthouden, zodat een retry hem niet herhaalt', async () => {
187 // Mastodon herhaalt dagenlang; zonder cache doet elke herhaling de fetch
188 // opnieuw. De tweede bezorging hoort geen tweede fetch op te leveren.
189 const MIS = 'https://203.0.113.10/notes/mislukt';
190 bron = null; // de stub geeft 404
191 const act = () => doorgestuurd({ id: MIS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
192 opgehaald = [];
193 assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
194 const na1 = opgehaald.filter((v) => v.url === MIS).length;
195 assert.ok(na1 >= 1, 'de eerste poging haalt wel op');
196 opgehaald = [];
197 assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
198 assert.equal(opgehaald.filter((v) => v.url === MIS).length, 0, 'de tweede niet meer');
199});
200
201test.after(() => { globalThis.fetch = echteFetch; });
Note: See TracBrowser for help on using the repository browser.