| 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.
|
|---|
| 14 | import { test } from 'node:test';
|
|---|
| 15 | import assert from 'node:assert/strict';
|
|---|
| 16 |
|
|---|
| 17 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 18 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 19 |
|
|---|
| 20 | const dbMod = await import('../src/config/database.js');
|
|---|
| 21 | const db = dbMod.default;
|
|---|
| 22 | dbMod.initializeDatabase();
|
|---|
| 23 | const express = (await import('express')).default;
|
|---|
| 24 | const routes = (await import('../src/routes/activitypub.js')).default;
|
|---|
| 25 |
|
|---|
| 26 | db.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.
|
|---|
| 30 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
|
|---|
| 31 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s2', 'oma', 'oma', 'u1');
|
|---|
| 32 |
|
|---|
| 33 | const app = express();
|
|---|
| 34 | app.use(routes);
|
|---|
| 35 | const server = app.listen(0);
|
|---|
| 36 | await new Promise((r) => server.once('listening', r));
|
|---|
| 37 | const port = server.address().port;
|
|---|
| 38 | test.after(() => server.close());
|
|---|
| 39 |
|
|---|
| 40 | /// Ask WebFinger for a resource while the server believes it is served at `base`.
|
|---|
| 41 | async 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.
|
|---|
| 54 | const actorOf = (body) => body.links.find((l) => l.rel === 'self').href;
|
|---|
| 55 |
|
|---|
| 56 | test('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 |
|
|---|
| 62 | test('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 |
|
|---|
| 69 | test('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 |
|
|---|
| 88 | test('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 |
|
|---|
| 95 | test('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 |
|
|---|
| 100 | test('a malformed resource is a 400', async () => {
|
|---|
| 101 | const { status } = await finger('https://test.example/ap/users/kid');
|
|---|
| 102 | assert.equal(status, 400);
|
|---|
| 103 | });
|
|---|