source: Klonkt/test/webfinger-bare-host.test.js@ 26c5f71

main
Last change on this file since 26c5f71 was 26c5f71, checked in by Bart <bart@…>, 5 weeks ago

WebFinger: een kale host vindt de primaire actor

acct:<host>@<host> is hoe Shaer een Ward adresseert zonder iemands slug te
kennen, maar WebFinger zocht het gebruikersdeel alleen op als site-slug. Een
kale host gaf dus altijd een 404, hoe je hem ook spelde.

Daarbovenop: 🩵.is.wildenvrij.nl en xn--zz9h.is.wildenvrij.nl zijn één host.
Een geplakte URL wordt door elke URL-parser stil gepunycode, een getypte
handle niet. asciiHost() vergelijkt via WHATWG URL, dus beide spellingen
komen bij dezelfde actor uit, en PUBLIC_BASE_URL mag ook beide vormen.

Zes tests, waarvan twee de terugval bewaken: een onbekende gebruiker blijft
404 en een kale host die niet van ons is ook. Anders koppelt een typefout een
kind stilletjes aan het verkeerde account.

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

  • Property mode set to 100644
File size: 4.9 KB
Line 
1// One Ward, however you spell its address.
2//
3// Shaer never asks for a URL. You type a handle, and its `Handle` parser turns a
4// bare host into `acct:<host>@<host>` — the WebFinger convention for "give me
5// this server's primary actor". WebFinger here only ever looked the user part up
6// as a site slug, so that resource 404'd and the app could not find a Ward it was
7// pointed straight at.
8//
9// The emoji host makes the second half of the problem visible. Foundation's URL
10// (and Node's, and every browser's) silently punycodes a host, so a pasted
11// `https://🩵.is.wildenvrij.nl` arrives as `xn--zz9h.is.wildenvrij.nl` while a
12// typed `🩵.is.wildenvrij.nl` arrives verbatim. Same Ward, two spellings, and a
13// byte comparison says they are strangers.
14import { test } from 'node:test';
15import assert from 'node:assert/strict';
16
17process.env.DATABASE_PATH = ':memory:';
18process.env.PUBLIC_BASE_URL = 'https://test.example';
19
20const dbMod = await import('../src/config/database.js');
21const db = dbMod.default;
22dbMod.initializeDatabase();
23const express = (await import('express')).default;
24const routes = (await import('../src/routes/activitypub.js')).default;
25
26db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
27 .run('u1', 'u1', 'u1@t', 'x', 'god');
28// `kid` is the primary site; `oma` is a second public site that must NOT be
29// what a bare host resolves to.
30db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
31db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s2', 'oma', 'oma', 'u1');
32
33const app = express();
34app.use(routes);
35const server = app.listen(0);
36await new Promise((r) => server.once('listening', r));
37const port = server.address().port;
38test.after(() => server.close());
39
40/// Ask WebFinger for a resource while the server believes it is served at `base`.
41async function finger(resource, base = 'https://test.example') {
42 const previous = process.env.PUBLIC_BASE_URL;
43 process.env.PUBLIC_BASE_URL = base;
44 try {
45 const url = `http://127.0.0.1:${port}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
46 const res = await fetch(url);
47 return { status: res.status, body: res.status === 200 ? await res.json() : null };
48 } finally {
49 process.env.PUBLIC_BASE_URL = previous;
50 }
51}
52
53/// The `self` link is the actor the client will actually fetch next.
54const actorOf = (body) => body.links.find((l) => l.rel === 'self').href;
55
56test('a normal handle still resolves (the case that already worked)', async () => {
57 const { status, body } = await finger('acct:oma@test.example');
58 assert.equal(status, 200);
59 assert.equal(actorOf(body), 'https://test.example/ap/users/oma', 'a named slug wins over the primary fallback');
60});
61
62test('a bare host resolves the primary actor', async () => {
63 // The whole bug: this is what Shaer sends when you type `test.example`.
64 const { status, body } = await finger('acct:test.example@test.example');
65 assert.equal(status, 200, 'the bare host is a valid address, not a 404');
66 assert.equal(actorOf(body), 'https://test.example/ap/users/kid', 'and it means the PRIMARY site, not just any site');
67});
68
69test('unicode and punycode spellings of one host find one Ward', async () => {
70 const unicode = 'https://🩵.is.wildenvrij.nl';
71 const punycode = 'https://xn--zz9h.is.wildenvrij.nl';
72
73 const typed = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', unicode);
74 const pasted = await finger('acct:xn--zz9h.is.wildenvrij.nl@xn--zz9h.is.wildenvrij.nl', unicode);
75
76 assert.equal(typed.status, 200, 'typed by hand: the emoji host');
77 assert.equal(pasted.status, 200, 'pasted as a URL: the client already punycoded it');
78 assert.deepEqual(actorOf(typed.body), actorOf(pasted.body), 'both spellings are the same Ward');
79
80 // And it does not matter which spelling the server itself is configured with.
81 const configuredAscii = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', punycode);
82 assert.equal(configuredAscii.status, 200, 'PUBLIC_BASE_URL may be written either way too');
83
84 assert.equal(typed.body.subject, 'acct:kid@xn--zz9h.is.wildenvrij.nl',
85 'the subject we answer with is the canonical one, never the alias that was asked for');
86});
87
88test('an unknown user is still a 404', async () => {
89 // The fallback must not turn every miss into the primary actor, or a typo
90 // silently connects a child to the wrong account.
91 const { status } = await finger('acct:nobody@test.example');
92 assert.equal(status, 404);
93});
94
95test('a bare host that is not ours is still a 404', async () => {
96 const { status } = await finger('acct:elders.example@elders.example');
97 assert.equal(status, 404, 'we only answer for the host we are actually serving');
98});
99
100test('a malformed resource is a 400', async () => {
101 const { status } = await finger('https://test.example/ap/users/kid');
102 assert.equal(status, 400);
103});
Note: See TracBrowser for help on using the repository browser.