source: Klonkt/test/reply-friends-only.test.js@ cb3001e

main
Last change on this file since cb3001e was 04d5aeb, checked in by Robin <roboburr@â€Ļ>, 5 weeks ago

Replies op friends-only posts: de deur die nooit open kon

De hangende reply-klacht (Robins schermafdruk, 2-8: 502
cannot_resolve_inReplyTo) bleek een keten van drie schakels. Een
friends-post slaat fan_only = 1 op. De /ap/notes-route verborg elke
fan_only post voor IEDEREEN, zonder ooit naar de Signature-header te
kijken. En resolveRemoteNote haalde zelfs de eigen notes over publiek
HTTPS op. De gesigneerde resolutie die het reply-pad sinds 30-7 doet
klopte dus aan bij een deur die niet open kon: elke reply op een
friends-only post (Shaers standaard!) stierf voor de aflevering.
Publieke posts deden het wel, vandaar dat het grillig leek.

Twee reparaties. EEN: een note die hier woont verlaat het pand niet
meer. resolveRemoteNote bouwt hem uit de database (localNoteObject,
localActorObject, ook in de thread-klim), waarbij de eigen host in
ASCII vergeleken wordt: xn--zz9h.example IS het hart-domein, Barts
WebFinger-les van vanochtend, nu ook op het reply-pad en in
postIdFromNoteUrl. Niet-publieke notes alleen voor de eigen C2S-caller
(forSlug); een hairpin-fetch die op een thuisserver achter een tunnel
faalt is er niet meer.

TWEE: authorized fetch op GET /ap/notes/:id. Een geverifieerde follower
verdient de friends-only Note (noteAudience/mayReadNote); een vreemde
krijgt exact dezelfde 404 als vroeger, een geblokkeerde actor ook (de
staande regel: gesigneerde fetch van een geblokkeerde verdient de lege
verzameling, domein-blocks incluis) en direct wordt nooit over GET
geserveerd.

Changed files:
src/services/ActivityPubService.js

  • asciiOrigin/isOwnUrl: hostvergelijking via WHATWG URL, geen bytes
  • postIdFromNoteUrl: ASCII-origins in plaats van startsWith
  • localNoteObject/localActorObject: eigen notes en actors uit de DB
  • resolveRemoteNote: kortsluiting op alle drie de fetch-punten
  • noteAudience/mayReadNote: de leespoort, ook in de default-export

src/routes/activitypub.js

  • /ap/notes/:id: fan_only niet meer in de SELECT maar achter de poort; verifyRequest beslist, try eromheen (Express 4 vangt een async rejection niet: een fout werd een eeuwig hangende request, precies zo gevonden tijdens het bouwen)

New file:
test/reply-friends-only.test.js

  • reply op eigen friends-post resolvet lokaal (geen server achter het testdomein: HTTP zou 502 geven) en threadt onder de post
  • unicode- en punycode-spelling zijn een host
  • onbestaande note blijft luid 502
  • mayReadNote-matrix: follower/vreemde/blocked/domein-block/direct
  • route: vreemde 404, garbage-signature 404, direct 404, publiek 200

remarks: de suite staat op 384. Wat dit NIET oplost: een reply waarvan
de parent op een derde server staat die zelf geen authorized fetch
doet; dat is de andere kant van dezelfde deur en die is van hen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@â€Ļ>

  • Property mode set to 100644
File size: 7.7 KB
RevLine 
[04d5aeb]1// A reply to a friends-only post, however its address is spelled.
2//
3// The chain that broke on Shaer (Robins schermafdruk, 2-8: "Server said 502:
4// cannot_resolve_inReplyTo"): a friends-visibility post stores fan_only = 1,
5// the /ap/notes route hid every fan_only post from EVERYONE without ever
6// reading the Signature header, and resolveRemoteNote fetched even the
7// server's OWN notes over public HTTPS. So the signed resolution the reply
8// path performs knocked on a door that could never open, and every reply to
9// a friends-only post (Shaer's default!) died before delivery. Public posts
10// resolved fine, which made it look intermittent.
11//
12// Two fixes, two test groups. One: a note living on this server resolves
13// from the DB, no HTTP, in any spelling of our own host (đŸŠĩ.example IS
14// xn--zz9h.example, Barts WebFinger-les). Two: the /ap/notes route now does
15// authorized fetch: a verified follower earns the friends-only Note, a
16// stranger keeps getting the exact same 404 as before, and 'direct' is never
17// served over GET at all.
18import { test } from 'node:test';
19import assert from 'node:assert/strict';
20
21process.env.DATABASE_PATH = ':memory:';
22process.env.PUBLIC_BASE_URL = 'https://xn--zz9h.example';
23
24const dbMod = await import('../src/config/database.js');
25const db = dbMod.default;
26dbMod.initializeDatabase();
27const AP = await import('../src/services/ActivityPubService.js');
28const express = (await import('express')).default;
29const routes = (await import('../src/routes/activitypub.js')).default;
30
31db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
32 .run('u1', 'u1', 'u1@t', 'x', 'god');
33db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)')
34 .run('s1', 'kid', 'kid', 'u1');
35const site = db.prepare("SELECT * FROM sites WHERE slug = 'kid'").get();
36const user = db.prepare("SELECT * FROM users WHERE id = 'u1'").get();
37
38const insertPost = db.prepare(`INSERT INTO posts
39 (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
40 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'),datetime('now'))`);
41insertPost.run('p-friends', 's1', 'n-friends', 'u1', '', '<p>alleen vrienden</p>', '', 'published', 'post', 'nl', 1, 'friends');
42insertPost.run('p-public', 's1', 'n-public', 'u1', '', '<p>iedereen</p>', '', 'published', 'post', 'nl', 0, 'public');
43insertPost.run('p-direct', 's1', 'n-direct', 'u1', '', '<p>persoonlijk</p>', '', 'published', 'post', 'nl', 1, 'direct');
44
45const app = express();
46app.use(routes);
47const server = app.listen(0);
48await new Promise((r) => server.once('listening', r));
49const port = server.address().port;
50test.after(() => server.close());
51
52const apGet = (path, headers = {}) =>
53 fetch(`http://127.0.0.1:${port}${path}`, { headers: { Accept: 'application/activity+json', ...headers } });
54
55// ── EÊn: de eigen note resolven zonder HTTP ──────────────────────────────
56
57test('a C2S reply to the own friends-only post resolves its parent locally', async () => {
58 // There is no server behind xn--zz9h.example: if the parent resolution
59 // still went over HTTP this would 502. It resolves from the DB instead.
60 const r = await AP.ingestOutboxActivity(site, user, {
61 type: 'Create',
62 object: {
63 type: 'Note', content: '<p>hoi</p>', source: { content: 'hoi' },
64 inReplyTo: 'https://xn--zz9h.example/ap/notes/p-friends',
65 to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
66 },
67 });
68 assert.equal(r.status, 201, JSON.stringify(r));
69 // And it threads under the post: findThreadTarget recognized the URL as ours.
70 const row = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(r.id);
71 assert.equal(row && row.post_id, 'p-friends');
72});
73
74test('the unicode spelling of our own host is still our own host', async () => {
75 // Foundation, Node and every browser silently punycode a URL; a typed one
76 // arrives verbatim. Both spellings must reach the same parent (the same
77 // lesson WebFinger learned on 2-8, now on the reply path).
78 const r = await AP.ingestOutboxActivity(site, user, {
79 type: 'Create',
80 object: {
81 type: 'Note', content: '<p>nogmaals</p>', source: { content: 'nogmaals' },
82 inReplyTo: 'https://\u{1FA75}.example/ap/notes/p-friends', // đŸŠĩ.example ⇒ xn--zz9h.example
83 to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
84 },
85 });
86 assert.equal(r.status, 201, JSON.stringify(r));
87 const row = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(r.id);
88 assert.equal(row && row.post_id, 'p-friends');
89});
90
91test('a reply to a nonexistent own note still fails, loudly', async () => {
92 const r = await AP.ingestOutboxActivity(site, user, {
93 type: 'Create',
94 object: {
95 type: 'Note', content: '<p>niks</p>', source: { content: 'niks' },
96 inReplyTo: 'https://xn--zz9h.example/ap/notes/bestaat-niet',
97 to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
98 },
99 });
100 assert.equal(r.status, 502);
101 assert.equal(r.error, 'cannot_resolve_inReplyTo');
102});
103
104// ── Twee: de leespoort (authorized fetch) ────────────────────────────────
105
106test('mayReadNote: the full matrix', () => {
107 const friends = { fan_only: 1, ap_visibility: 'friends' };
108 const direct = { fan_only: 1, ap_visibility: 'direct' };
109 const pub = { fan_only: 0, ap_visibility: 'public' };
110 const oma = 'https://elders.example/ap/users/oma';
111 db.prepare('INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES (?,?,?)')
112 .run('kid', oma, 'https://elders.example/inbox');
113
114 assert.equal(AP.mayReadNote(site, pub, null), true, 'public needs nobody');
115 assert.equal(AP.mayReadNote(site, friends, oma), true, 'a follower earns the friends-only note');
116 assert.equal(AP.mayReadNote(site, friends, 'https://elders.example/ap/users/vreemde'), false, 'a stranger does not');
117 assert.equal(AP.mayReadNote(site, friends, null), false, 'no verified actor, no note');
118 assert.equal(AP.mayReadNote(site, direct, oma), false, 'direct is never served over GET');
119
120 // A blocked actor's signed fetch earns the empty set (the standing rule),
121 // follower row or not.
122 db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES ('kid', ?, 'actor')").run(oma);
123 assert.equal(AP.mayReadNote(site, friends, oma), false, 'actor block wins from the follower row');
124 db.prepare('DELETE FROM ap_blocks').run();
125 db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES ('kid', 'elders.example', 'domain')").run();
126 assert.equal(AP.mayReadNote(site, friends, oma), false, 'domain block covers its actors');
127 db.prepare('DELETE FROM ap_blocks').run();
128});
129
130test('the route: a stranger keeps the exact same 404, public stays public', async () => {
131 // Unsigned: the friends-only note does not exist for you.
132 assert.equal((await apGet('/ap/notes/p-friends')).status, 404);
133 // A signature that cannot be verified is no signature. The keyId points at
134 // a blocked IP so the SSRF guard refuses it instantly: same null outcome as
135 // an unreachable host, without a DNS lookup that can hang the test.
136 assert.equal((await apGet('/ap/notes/p-friends', {
137 Signature: 'keyId="https://127.0.0.1:1/u/x#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="aGVsbG8="',
138 Date: new Date().toUTCString(),
139 })).status, 404);
140 // Direct is addressed to people: never served over GET, signed or not.
141 assert.equal((await apGet('/ap/notes/p-direct')).status, 404);
142 // And the public post is untouched by the gate.
143 const pub = await apGet('/ap/notes/p-public');
144 assert.equal(pub.status, 200);
145 const note = await pub.json();
146 assert.equal(note.id, 'https://xn--zz9h.example/ap/notes/p-public');
147});
Note: See TracBrowser for help on using the repository browser.