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

main
Last change on this file since bedd7a6 was bedd7a6, checked in by roboburr <roboburr@…>, 5 weeks ago

Doorstuur-ophaal ondertekenen op de gedeelde inbox, en elke uitgang een reden

Twee dingen die de meting van shaer-drf in de weg stonden.

  1. signedGetJson valt bij een lege slug terug op een ONBETEKENDE GET. Op de gedeelde inbox is slugParam null, en dat is nu juist waar doorstuurverkeer landt, want we adverteren een sharedInbox. Een bron in secure mode was langs die weg dus helemaal niet te dereferencen. Dit is dezelfde fix die verifyRequest in shaer-afq kreeg -- elke lokale actor is een geldige ondertekenaar -- en shaer-afq voorspelde dit gat ook letterlijk, maar nam alleen fetchActor mee. Onbetekend blijft eerst, zodat een vreemde ons geen ondertekend verzoek naar een adres van zijn keuze kan laten sturen.
  1. Vijf van de zes uitgangen van dereferenceForwarded keerden stil terug. Daardoor was een weigering niet te onderscheiden van een versmalling die te ver dicht ging: op boiert stond 5x 'unknown inReplyTo' tegenover 29x signer mismatch, zonder dat te zien was waar de rest bleef. Elke uitgang noemt nu zijn reden, en 'fetch failed' is losgetrokken van 'attributedTo mismatch' -- het eerste is een onbereikbare bron, het tweede zegt de bron zelf dat iemand anders de auteur is.

Gecontroleerd dat de nieuwe test bijt: haal anySigningSlug() eruit en geval 13
valt om. Suite 466/466.

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

  • Property mode set to 100644
File size: 11.6 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('op de GEDEELDE inbox wordt de bron ook ondertekend opgehaald', async () => {
202 // Doorstuurverkeer landt op /ap/inbox, want we adverteren een sharedInbox --
203 // en daar is slugParam null. signedGetJson valt bij een lege slug terug op een
204 // ONBETEKENDE GET, dus een bron in secure mode was langs deze weg helemaal niet
205 // te dereferencen. Elke lokale actor is een geldige ondertekenaar, net als in
206 // verifyRequest sinds shaer-afq.
207 const GESLOTEN = 'https://203.0.113.10/notes/secure-mode'; // eigen id: buiten de negatieve cache
208 const note = {
209 id: GESLOTEN, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE,
210 content: '<p>uit secure mode</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
211 };
212 const stub = globalThis.fetch;
213 const pogingen = [];
214 globalThis.fetch = async (url, opts = {}) => {
215 if (String(url) !== GESLOTEN) return stub(url, opts);
216 const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
217 pogingen.push(ondertekend);
218 return ondertekend
219 ? new Response(JSON.stringify(note), { status: 200, headers: { 'content-type': 'application/activity+json' } })
220 : new Response('unauthorized', { status: 401 });
221 };
222 const status = await AP.handleInbox(req(doorgestuurd(note)), null, alsDoorstuurder);
223 globalThis.fetch = stub;
224 assert.equal(status, 202, 'de doorgestuurde Create hoort geaccepteerd te worden');
225 assert.deepEqual(pogingen, [false, true], 'eerst onbetekend, daarna pas ondertekend');
226});
227
228test.after(() => { globalThis.fetch = echteFetch; });
Note: See TracBrowser for help on using the repository browser.