source: Klonkt/test/authorized-fetch.test.js@ 0bafe9e

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

Niet standaard ondertekenen bij de sleutel-ophaal (regressie van 9561d58)

Robins vraag legde bloot wat ik een uur eerder zelf had verslechterd.
verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die URL
komt uit een header die iedereen mag sturen. Sinds 9561d58 werd dat verzoek
standaard ondertekend als een van onze actors -- dus kon een volslagen onbekende
met één POST boiert een ONDERTEKEND verzoek laten sturen naar een adres van zijn
keuze, met onze identiteit eronder. Dat is hoe een instance op een blocklist
belandt.

fetchActor probeert nu onbetekend eerst en tekent alleen als dat niets bruikbaars
oplevert. Voor een instance met authorized fetch verandert er niets behalve één
extra verzoek; voor alle andere verdwijnt de handtekening weer. Een document dat
onbetekend wél komt maar zonder publicKey telt als mislukt -- voor een
verificatie heb je daar niets aan.

Twee tests erbij die de VOLGORDE vastleggen, want dat is hier de hele zaak: een
open instance krijgt precies één, onbetekend verzoek, en een kaal document leidt
tot een tweede, ondertekende poging.

Wat hiermee NIET is opgelost, en in shaer-afq staat: de ophaal blijft aanroepbaar
door een onbekende, alleen niet meer namens ons. Het smaller maken van de
dereference (alleen bij een inReplyTo die we kennen) en het onthouden van
mislukte pogingen zijn aparte stappen.

Suite 454/454.

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

  • Property mode set to 100644
File size: 5.2 KB
Line 
1// Authorized fetch (shaer-afq): een instance in Mastodons secure mode geeft zijn
2// actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
3// verzoek. Zonder die handtekening kregen we 401, vonden we geen sleutel, en
4// wezen we elke correct ondertekende Follow van die instance af. Op boiert.eu
5// bleven daardoor vier accounts eindeloos hangen.
6//
7// De fetch is hier gestubd: het gaat om de vraag OF er ondertekend wordt en of
8// er wordt teruggevallen, niet om echte HTTP of crypto.
9//
10// Run: npm test
11
12import { test } from 'node:test';
13import assert from 'node:assert/strict';
14
15process.env.DATABASE_PATH = ':memory:';
16process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
17
18const dbMod = await import('../src/config/database.js');
19const db = dbMod.default;
20dbMod.initializeDatabase();
21const AP = await import('../src/services/ActivityPubService.js');
22
23db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
24 .run('u1', 'u1', 'u1@test', 'x', 'god');
25db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
26
27// IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline.
28const OPEN_ACTOR = 'https://203.0.113.30/users/open'; // gewone instance
29const SECURE_ACTOR = 'https://203.0.113.40/users/gesloten'; // authorized fetch
30
31let verzoeken = [];
32const echteFetch = globalThis.fetch;
33globalThis.fetch = async (url, opts = {}) => {
34 const u = String(url);
35 const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
36 verzoeken.push({ url: u, ondertekend });
37 const doc = (id) => new Response(JSON.stringify({
38 id, type: 'Person', preferredUsername: 'x', inbox: `${id}/inbox`,
39 publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: '-----BEGIN PUBLIC KEY-----\nx\n-----END PUBLIC KEY-----' },
40 }), { status: 200, headers: { 'content-type': 'application/activity+json' } });
41
42 if (u === OPEN_ACTOR) return doc(OPEN_ACTOR);
43 // De kern van secure mode: onbetekend is het 401, ondertekend krijg je hem wel.
44 if (u === SECURE_ACTOR) return ondertekend ? doc(SECURE_ACTOR) : new Response('unauthorized', { status: 401 });
45 return new Response('not found', { status: 404 });
46};
47
48test('een actor achter authorized fetch wordt nu wél opgehaald', async () => {
49 verzoeken = [];
50 const actor = await AP.fetchActor(SECURE_ACTOR, { asSlug: 'me' });
51 assert.ok(actor, 'de actor hoort binnen te komen');
52 assert.equal(actor.id, SECURE_ACTOR);
53 assert.ok(verzoeken.some((v) => v.url === SECURE_ACTOR && v.ondertekend), 'het verzoek hoort ondertekend te zijn');
54});
55
56test('zonder ondertekenaar blijft dezelfde actor onbereikbaar', async () => {
57 // Dit is precies het oude gedrag, en het bewijst dat de stub echt onderscheid
58 // maakt in plaats van altijd mee te werken.
59 const actor = await AP.fetchActor(SECURE_ACTOR);
60 assert.equal(actor, null);
61});
62
63test('een gewone instance blijft werken, ondertekend of niet', async () => {
64 assert.ok(await AP.fetchActor(OPEN_ACTOR), 'onbetekend');
65 assert.ok(await AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }), 'ondertekend');
66});
67
68test('een OPEN instance wordt NIET ondertekend opgehaald', () => {
69 // De veiligheidskant van shaer-afq: verifyRequest haalt de keyId-URL op
70 // voordat er iets geverifieerd is, en die URL komt uit een header die iedereen
71 // mag sturen. Tekenden we standaard, dan kan een vreemde ons een ondertekend
72 // verzoek naar een adres van zijn keuze laten sturen, met onze identiteit
73 // eronder. Onbetekend eerst dus, en alleen tekenen als het anders niet lukt.
74 verzoeken = [];
75 return AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }).then((actor) => {
76 assert.ok(actor);
77 assert.equal(verzoeken.length, 1, 'één poging, geen tweede');
78 assert.equal(verzoeken[0].ondertekend, false, 'en die was onbetekend');
79 });
80});
81
82test('een document zonder sleutel telt als mislukt en leidt tot een ondertekende poging', async () => {
83 // Sommige instances serveren onbetekend wel iets, maar zonder publicKey. Voor
84 // een verificatie hebben we daar niets aan.
85 const KAAL = 'https://203.0.113.50/users/kaal';
86 const stubOrig = globalThis.fetch;
87 globalThis.fetch = async (url, opts = {}) => {
88 const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
89 if (String(url) === KAAL) {
90 verzoeken.push({ url: String(url), ondertekend });
91 const body = ondertekend
92 ? { id: KAAL, type: 'Person', publicKey: { id: `${KAAL}#k`, owner: KAAL, publicKeyPem: 'x' } }
93 : { id: KAAL, type: 'Person' }; // kaal: geen sleutel
94 return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/activity+json' } });
95 }
96 return stubOrig(url, opts);
97 };
98 verzoeken = [];
99 const actor = await AP.fetchActor(KAAL, { asSlug: 'me' });
100 globalThis.fetch = stubOrig;
101 assert.ok(actor.publicKey && actor.publicKey.publicKeyPem, 'de ondertekende poging levert de sleutel');
102 assert.deepEqual(verzoeken.map((v) => v.ondertekend), [false, true], 'eerst onbetekend, daarna pas ondertekend');
103});
104
105test('een onbekende actor blijft null, ondertekend of niet', async () => {
106 assert.equal(await AP.fetchActor('https://203.0.113.99/users/weg', { asSlug: 'me' }), null);
107});
108
109test.after(() => { globalThis.fetch = echteFetch; });
Note: See TracBrowser for help on using the repository browser.