| 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(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. */
|
|---|
| 48 | function 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 | }
|
|---|
| 58 | const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
|
|---|
| 59 | const alsDoorstuurder = { id: DOORSTUURDER };
|
|---|
| 60 |
|
|---|
| 61 | test('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 |
|
|---|
| 73 | test('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 |
|
|---|
| 82 | test('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 |
|
|---|
| 91 | test('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 |
|
|---|
| 97 | test('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 |
|
|---|
| 103 | test('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 |
|
|---|
| 111 | test('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 |
|
|---|
| 121 | test('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 |
|
|---|
| 129 | test.after(() => { globalThis.fetch = echteFetch; });
|
|---|