source: Klonkt/test/post-access.test.js@ 1d5ffc0

main
Last change on this file since 1d5ffc0 was af2cc73, checked in by Bart <bart@…>, 3 weeks ago

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

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

  • Property mode set to 100644
File size: 7.8 KB
Line 
1// Wie mag wat zien van een bericht -- op ÉÉN plek.
2//
3// Het besluit zat alleen in de postroute, waar het een andere PAGINA rendert
4// (paid-gate/fan-gate). De tijdlijn gaat hele berichten tonen en heeft hetzelfde
5// besluit nodig als een stuk. Twee plekken die allebei zelf bepalen wie een
6// betaalde tekst mag lezen, is een lek dat op een gelegenheid wacht -- vandaar
7// deze module, en vandaar dat de toetsen hier scherper staan dan elders.
8import { test } from 'node:test';
9import assert from 'node:assert/strict';
10
11process.env.DATABASE_PATH = ':memory:';
12process.env.PUBLIC_BASE_URL = 'https://test.example';
13
14const dbMod = await import('../src/config/database.js');
15dbMod.initializeDatabase();
16const { postAccess, canReadBody, paidTeaser } = await import('../src/services/PostAccessService.js');
17
18const SITE = { id: 's1', slug: 'demo', owner_id: 'u1' };
19const OWNER = { id: 'u1', role: 'god' };
20const VREEMDE = null;
21const INGELOGD = { id: 'u2', role: 'user' };
22
23const post = (extra = {}) => ({
24 id: 'p1', slug: 'hallo', status: 'published', site_id: 's1', author_id: 'u1',
25 content: '<p>eerste alinea</p><p>tweede alinea</p>', ...extra,
26});
27
28test('een gewoon bericht is voor iedereen', () => {
29 assert.equal(postAccess(post(), { user: VREEMDE, site: SITE }), 'full');
30 assert.equal(canReadBody(post(), { user: VREEMDE, site: SITE }), true);
31});
32
33test('een concept is van de maker, en van niemand anders', () => {
34 const concept = post({ status: 'draft' });
35 assert.equal(postAccess(concept, { user: VREEMDE, site: SITE }), 'forbidden');
36 assert.equal(postAccess(concept, { user: INGELOGD, site: SITE }), 'forbidden');
37 assert.equal(postAccess(concept, { user: OWNER, site: SITE }), 'full', 'de eigenaar ziet zijn eigen concept');
38});
39
40test('fan-only vraagt om inloggen, niet om betalen', () => {
41 const f = post({ fan_only: 1 });
42 assert.equal(postAccess(f, { user: VREEMDE, site: SITE }), 'fan');
43 assert.equal(postAccess(f, { user: INGELOGD, site: SITE }), 'full', 'ingelogd is genoeg');
44});
45
46test('betaald blijft betaald tot er een geldig bewijs ligt', () => {
47 const b = post({ paid: 1 });
48 assert.equal(postAccess(b, { user: VREEMDE, site: SITE }), 'paid');
49 assert.equal(postAccess(b, { user: INGELOGD, site: SITE }), 'paid',
50 'inloggen koopt niets: een Klonkt-account is geen supporter');
51 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: 'hallo' }), 'full');
52});
53
54test('een bewijs voor een ANDER bericht opent dit bericht niet', () => {
55 // De gevaarlijke vorm: één keer betalen en dan de hele site lezen.
56 const b = post({ paid: 1 });
57 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: 'een-ander' }), 'paid');
58 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: '' }), 'paid');
59 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, unlockedSlug: null }), 'paid');
60});
61
62test('betaald WINT van fan-only, en die volgorde is geen detail', () => {
63 // Een bericht dat allebei is gaat open met een passkey, niet met een login.
64 // Andersom belandt een anonieme supporter op het inlogscherm en ziet hij de
65 // ontgrendelknop nooit -- de reden dat deze volgorde in de route stond.
66 const beide = post({ paid: 1, fan_only: 1 });
67 assert.equal(postAccess(beide, { user: VREEMDE, site: SITE }), 'paid');
68 assert.equal(postAccess(beide, { user: INGELOGD, site: SITE }), 'paid',
69 'ook ingelogd: de betaalmuur staat vooraan');
70
71 // TWEE POORTEN ACHTER ELKAAR, en dat is de bestaande werking -- overgenomen
72 // uit de route, niet hier bedacht. Wie betaalt maar niet ingelogd is, staat
73 // na het ontgrendelen alsnog voor de fanpoort:
74 assert.equal(postAccess(beide, { user: VREEMDE, site: SITE, unlockedSlug: 'hallo' }), 'fan');
75 // Betalen ÉN ingelogd komt er wel doorheen.
76 assert.equal(postAccess(beide, { user: INGELOGD, site: SITE, unlockedSlug: 'hallo' }), 'full');
77});
78
79// ── de fanpoort stelt eindelijk de juiste vraag ───────────────────────────
80// fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort
81// vroeg om een lokaal account. isFollower komt uit OpenWebAuthService: een
82// bezoeker die via FEP-61cf bewees @iemand@ergens te zijn en die deze site volgt.
83
84test('een bewezen volger komt door de fanpoort', () => {
85 const f = post({ fan_only: 1 });
86 assert.equal(postAccess(f, { user: VREEMDE, site: SITE }), 'fan', 'zonder bewijs nog steeds dicht');
87 assert.equal(postAccess(f, { user: VREEMDE, site: SITE, isFollower: true }), 'full',
88 'mét bewijs open -- geen account, geen wachtwoord, wel een volger');
89});
90
91test('maar volgen koopt geen betaald bericht', () => {
92 // De volgorde blijft: betaald wint van fan-only, en volgerschap is geen geld.
93 const b = post({ paid: 1, fan_only: 1 });
94 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true }), 'paid');
95 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true, unlockedSlug: 'hallo' }), 'full',
96 'betaald EN volger komt er wel doorheen');
97});
98
99test('volgen doet niets bij een concept', () => {
100 assert.equal(postAccess(post({ status: 'draft' }), { user: VREEMDE, site: SITE, isFollower: true }), 'forbidden');
101});
102
103test('de eigenaar loopt niet tegen zijn eigen muur op', () => {
104 assert.equal(postAccess(post({ paid: 1, fan_only: 1 }), { user: OWNER, site: SITE }), 'full');
105});
106
107test('geen bericht is geen toegang', () => {
108 assert.equal(postAccess(null, { user: OWNER, site: SITE }), 'forbidden');
109 assert.equal(postAccess(undefined, {}), 'forbidden');
110});
111
112test('de teaser lekt nooit voorbij de eerste alinea', () => {
113 // Dit is de hele belofte van een betaalmuur: wat hierna komt, komt er niet uit.
114 const t = paidTeaser(post({ paid: 1 }));
115 assert.match(t, /eerste alinea/);
116 assert.ok(!t.includes('tweede'), 'de tweede alinea blijft binnen');
117});
118
119test('een expliciete excerpt gaat voor', () => {
120 assert.equal(paidTeaser(post({ excerpt: 'zelf geschreven' })), 'zelf geschreven');
121});
122
123// ── Wat de tijdlijn krijgt ────────────────────────────────────────────────
124// De tijdlijn toont hele berichten, dus een gesloten bericht moet zijn plek
125// houden ZONDER dat de tekst meereist. Dat is geen sjabloonkwestie: wat niet
126// gerenderd wordt, kan ook niet per ongeluk getoond worden.
127
128const { postEntry } = await import('../src/services/PostAccessService.js');
129const renderBody = (p) => `<div>GEHEIM: ${p.content}</div>`;
130
131test('een open bericht krijgt zijn lijf', () => {
132 const e = postEntry(post(), { user: VREEMDE, site: SITE }, { renderBody });
133 assert.equal(e.access, 'full');
134 assert.match(e.content_html, /GEHEIM/);
135 assert.equal(e.teaser, null, 'geen teaser NAAST het lijf');
136});
137
138test('een betaald bericht reist zonder tekst', () => {
139 const e = postEntry(post({ paid: 1 }), { user: VREEMDE, site: SITE }, { renderBody });
140 assert.equal(e.access, 'paid');
141 assert.equal(e.content_html, null, 'het lijf wordt niet eens gerenderd');
142 assert.match(e.teaser, /eerste alinea/);
143 assert.ok(!JSON.stringify(e).includes('tweede alinea'), 'en niets erachter lekt mee');
144});
145
146test('een fan-bericht ook niet', () => {
147 const e = postEntry(post({ fan_only: 1 }), { user: VREEMDE, site: SITE }, { renderBody });
148 assert.equal(e.access, 'fan');
149 assert.equal(e.content_html, null);
150});
151
152test('een concept levert helemaal niets op', () => {
153 const e = postEntry(post({ status: 'draft' }), { user: VREEMDE, site: SITE }, { renderBody });
154 assert.equal(e.access, 'forbidden');
155 assert.equal(e.content_html, null);
156 assert.equal(e.teaser, null, 'ook geen teaser: over een concept valt niets te zeggen');
157});
158
159test('zonder renderer nog steeds geen lijf, en geen fout', () => {
160 const e = postEntry(post(), { user: VREEMDE, site: SITE });
161 assert.equal(e.access, 'full');
162 assert.equal(e.content_html, null);
163});
Note: See TracBrowser for help on using the repository browser.