| 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 |
|
|---|
| 11 | import { test } from 'node:test';
|
|---|
| 12 | import assert from 'node:assert/strict';
|
|---|
| 13 |
|
|---|
| 14 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 15 | process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
|
|---|
| 16 |
|
|---|
| 17 | const dbMod = await import('../src/config/database.js');
|
|---|
| 18 | const db = dbMod.default;
|
|---|
| 19 | dbMod.initializeDatabase();
|
|---|
| 20 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 21 |
|
|---|
| 22 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 23 | .run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 24 | db.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.
|
|---|
| 30 | const AUTEUR = 'https://203.0.113.10/users/anna';
|
|---|
| 31 | const DOORSTUURDER = 'https://203.0.113.20/users/relay';
|
|---|
| 32 | const 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.
|
|---|
| 35 | let bron = null;
|
|---|
| 36 | let opgehaald = [];
|
|---|
| 37 | const echteFetch = globalThis.fetch;
|
|---|
| 38 | globalThis.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.
|
|---|
| 49 | const ONZE_NOTE = 'https://203.0.113.10/notes/van-ons';
|
|---|
| 50 | db.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. */
|
|---|
| 54 | function 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 | }
|
|---|
| 64 | const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
|
|---|
| 65 | const alsDoorstuurder = { id: DOORSTUURDER };
|
|---|
| 66 |
|
|---|
| 67 | test('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 |
|
|---|
| 79 | test('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 |
|
|---|
| 88 | test('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 |
|
|---|
| 97 | test('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 |
|
|---|
| 103 | test('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 |
|
|---|
| 109 | test('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 |
|
|---|
| 117 | test('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 |
|
|---|
| 127 | test('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 |
|
|---|
| 135 | test('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 |
|
|---|
| 151 | test('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 |
|
|---|
| 159 | test('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 |
|
|---|
| 186 | test('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 |
|
|---|
| 201 | test.after(() => { globalThis.fetch = echteFetch; });
|
|---|