fedipod 1.48.1 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,16 @@ import * as podMedia from '../../pod/media.mjs';
8
8
  import * as social from '../../core/social.mjs';
9
9
  import { readBody, readMultipart } from './body.mjs';
10
10
 
11
+ // What a profile picture or header may be: an image of a kind every client
12
+ // shows, stored as that type and named by it, never by the client's filename.
13
+ // Anything else, a web page above all, would be served from the pod's own
14
+ // address and run there when opened.
15
+ const PROFILE_IMAGE_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif' };
16
+ const profileImageType = (f) => {
17
+ const t = String(f?.contentType || '').split(';')[0].trim().toLowerCase();
18
+ return PROFILE_IMAGE_EXT[t] ? t : null;
19
+ };
20
+
11
21
  export async function handle(api, ctx) {
12
22
  const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
13
23
 
@@ -38,13 +48,17 @@ export async function handle(api, ctx) {
38
48
  else form = await readBody(req);
39
49
 
40
50
  const cfg = { ...api.store.getConfig() };
51
+ for (const f of [ files.avatar, files.header ]) {
52
+ if (f?.data?.length && !profileImageType(f)) {
53
+ return send(422, { error: 'a profile picture or header must be a PNG, JPEG, GIF, WebP or AVIF image' });
54
+ }
55
+ }
41
56
  const putImage = async (f) => {
42
- const ext = (f.filename || '').includes('.')
43
- ? f.filename.split('.').pop().replace(/[^\w]/g, '') : 'bin';
44
- const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
57
+ const type = profileImageType(f);
58
+ const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + PROFILE_IMAGE_EXT[type];
45
59
  const url = api.urls.media + slug;
46
60
  await api.agent.publisher.ensureMediaContainer();
47
- await podMedia.write(api.agent.remote, url, f.data, f.contentType);
61
+ await podMedia.write(api.agent.remote, url, f.data, type);
48
62
  return url;
49
63
  };
50
64
 
@@ -84,6 +84,7 @@ export class MastoApi {
84
84
  store: this.store,
85
85
  subject: () => this.urls?.actor || 'https://localhost/',
86
86
  log: this.log,
87
+ liveKeys: () => new Set(this.tokens().map((t) => this._push.keyOf(t))),
87
88
  });
88
89
  return this._push;
89
90
  }
@@ -12,6 +12,10 @@ const AUTHZ_WINDOW_MS = 60_000;
12
12
  const AUTHZ_MAX_ATTEMPTS = 5;
13
13
  const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
14
14
  const MAX_APPS = 200; // registered third-party clients, capped
15
+ const MAX_REDIRECTS = 10; // return addresses one client may register
16
+ const MAX_REDIRECT_CHARS = 2048;
17
+ const REGISTRATION_WINDOW_MS = 60_000;
18
+ const MAX_REGISTRATIONS = 10; // registrations per window, from anyone
15
19
  const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
16
20
  const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
17
21
  const CLIENT_DOC_WINDOW_MS = 60_000;
@@ -48,12 +52,14 @@ const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+
48
52
  // fediverse-session library, and proves that sign-in back to /oauth/authorize.
49
53
  function sendPodSigninPage(res, client, webId, mount = '') {
50
54
  let asking = '';
55
+ let allow = 'Allow';
51
56
  if (client && (client.name || client.redirect)) {
52
57
  let where = '';
53
58
  try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
54
59
  const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
55
60
  asking = `<p><strong>${who}</strong> is asking to use your account`
56
61
  + `${where ? `, and you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
62
+ allow = `Allow ${who}`;
57
63
  }
58
64
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
59
65
  res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -64,13 +70,14 @@ h1{color:var(--heading);font-size:1.5rem}
64
70
  code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
65
71
  button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
66
72
  button[hidden]{display:none}
73
+ #cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
67
74
  #signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
68
- @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#5a8a4f}}</style></head><body>
75
+ @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
69
76
  <main>
70
77
  <h1>Sign in with your pod</h1>
71
78
  ${asking}
72
79
  <p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
73
- <p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
80
+ <p><button type="button" id="signin" hidden>${allow}</button><button type="button" id="cancel" hidden>Cancel</button></p>
74
81
  <p id="signin-status" role="status" aria-live="polite"></p>
75
82
  </main>
76
83
  <script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
@@ -324,12 +331,53 @@ export function registerApp(api, { name, website, redirectUris, scopes }) {
324
331
  clientSecret: crypto.randomBytes(32).toString('base64url'),
325
332
  name: String(name || 'client').slice(0, 200),
326
333
  website: String(website || '').slice(0, 500),
327
- redirectUris, scopes: String(scopes || 'read'), createdAt: Date.now(),
334
+ redirectUris, scopes: String(scopes || 'read').slice(0, 500), createdAt: Date.now(),
335
+ // Set when the owner first lets it sign in (touchApp): a client nobody
336
+ // has used is the first to go when the list is full.
337
+ usedAt: null,
328
338
  };
329
- api.store.write('oauth-apps.json', [...api.apps(), app].slice(-MAX_APPS));
339
+ const kept = api.apps();
340
+ while (kept.length >= MAX_APPS) kept.splice(kept.indexOf(leastUsed(kept)), 1);
341
+ api.store.write('oauth-apps.json', [...kept, app]);
330
342
  return app;
331
343
  }
332
344
 
345
+ // The client to push out of a full list: one nobody has signed in with,
346
+ // oldest first; failing that, the one used longest ago. A client registered
347
+ // before `usedAt` existed counts as used when it was made, so a flood of new
348
+ // registrations cannot push out the apps the owner already has.
349
+ function leastUsed(list) {
350
+ const never = list.filter((a) => a.usedAt === null);
351
+ const when = (a) => a.usedAt ?? a.createdAt ?? 0;
352
+ return (never.length ? never : list).reduce((a, b) => (when(b) < when(a) ? b : a));
353
+ }
354
+
355
+ export function touchApp(api, clientId) {
356
+ const all = api.apps();
357
+ const app = all.find((a) => a.clientId === clientId);
358
+ if (!app) return;
359
+ app.usedAt = Date.now();
360
+ api.store.write('oauth-apps.json', all);
361
+ }
362
+
363
+ // Why a registration's return addresses cannot be kept, or null. Registering
364
+ // needs no sign-in, so its size is the part a stranger controls.
365
+ export function redirectsProblem(raw) {
366
+ if (Array.isArray(raw) && raw.some((v) => typeof v !== 'string')) return 'redirect_uris must be text';
367
+ const list = parseRedirects(raw);
368
+ if (list.length > MAX_REDIRECTS) return `at most ${MAX_REDIRECTS} redirect_uris`;
369
+ if (list.some((u) => u.length > MAX_REDIRECT_CHARS)) return `a redirect_uri is at most ${MAX_REDIRECT_CHARS} characters`;
370
+ return null;
371
+ }
372
+
373
+ export function registrationLimited(api) {
374
+ const now = Date.now();
375
+ api.appRegistrations = (api.appRegistrations || []).filter((t) => now - t < REGISTRATION_WINDOW_MS);
376
+ if (api.appRegistrations.length >= MAX_REGISTRATIONS) return true;
377
+ api.appRegistrations.push(now);
378
+ return false;
379
+ }
380
+
333
381
  // A short-lived, single-use authorization code for a registered client, kept
334
382
  // apart from masto-tokens.json so the code is NOT a bearer until it is
335
383
  // exchanged with the client secret.
@@ -398,6 +446,13 @@ export async function handle(api, ctx) {
398
446
  // authorize step becomes a real login form.
399
447
  if (pathname === '/api/v1/apps' && req.method === 'POST') {
400
448
  const body = await readBody(req);
449
+ const problem = redirectsProblem(body.redirect_uris);
450
+ if (problem) return send(422, { error: problem });
451
+ if (registrationLimited(api)) {
452
+ api.log('app registration refused: too many this minute');
453
+ return send(429, { error: 'too many apps registered just now — try again in a minute' },
454
+ { 'retry-after': String(REGISTRATION_WINDOW_MS / 1000) });
455
+ }
401
456
  const redirectUris = parseRedirects(body.redirect_uris);
402
457
  const app = api.registerApp({ name: body.client_name, website: body.website,
403
458
  redirectUris, scopes: body.scopes });
@@ -543,6 +598,7 @@ export async function handle(api, ctx) {
543
598
  challenge: params.get('code_challenge') || null,
544
599
  challengeMethod: params.get('code_challenge_method') || null })
545
600
  : api.mintToken(client.scope);
601
+ if (app) touchApp(api, app.clientId);
546
602
  if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
547
603
  const target = new URL(redirect);
548
604
  target.searchParams.set('code', code);
@@ -647,6 +703,8 @@ export async function handle(api, ctx) {
647
703
  if (gone) {
648
704
  const kept = api.tokenRecords().filter(r => r.token !== gone);
649
705
  api.store.write('masto-tokens.json', kept);
706
+ // Its notifications stop with it.
707
+ if (api.push.get(gone)) api.push.drop(gone);
650
708
  api.log('client token revoked');
651
709
  }
652
710
  return send(200, {});
@@ -8,6 +8,9 @@
8
8
  // The verifier is injected so offline tests stub it, and wrapped so the
9
9
  // library (CJS, older jose) can be replaced without touching any caller.
10
10
 
11
+ import { scopeAllows } from './masto/oauth.mjs';
12
+ import { claimedWebId } from '../gateway/token-claims.mjs';
13
+
11
14
  export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
12
15
  let verify = verifier;
13
16
  const loadVerifier = async () => {
@@ -19,7 +22,14 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
19
22
  };
20
23
 
21
24
  return async function authenticate(req, pathname) {
22
- if (masto?.authed(req)) {
25
+ const bearer = masto?.tokenOf(req);
26
+ if (bearer) {
27
+ // The same permission the Mastodon routes ask of this token: a client
28
+ // allowed only to read does not post here either.
29
+ const need = req.method === 'GET' || req.method === 'HEAD' ? 'read' : 'write';
30
+ if (!scopeAllows(bearer.scope, need)) {
31
+ return { ok: false, status: 403, error: `this app was not allowed to ${need === 'read' ? 'read' : 'post'} for the account` };
32
+ }
23
33
  return { ok: true, webid: agent.remote?.webId || null, via: 'bearer' };
24
34
  }
25
35
  if (!req.headers.authorization) {
@@ -32,6 +42,12 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
32
42
  return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
33
43
  headers: { 'www-authenticate': 'DPoP' } };
34
44
  }
45
+ // A token naming anyone but the owner is refused before it is checked:
46
+ // checking fetches the documents it names, and a stranger names them.
47
+ const claimed = claimedWebId({ headers: { get: (n) => req.headers[n] ?? null } });
48
+ if (claimed && claimed !== agent.remote?.webId) {
49
+ return { ok: false, status: 403, error: 'this account belongs to its owner alone' };
50
+ }
35
51
  let webid;
36
52
  try {
37
53
  const v = await loadVerifier();
@@ -48,8 +64,10 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
48
64
  { header: req.headers.dpop, method: req.method, url: htu },
49
65
  ));
50
66
  } catch (e) {
67
+ // The reason stays here: it can carry what was read from an address
68
+ // the token named.
51
69
  log(`c2s auth: token rejected — ${e.message}`);
52
- return { ok: false, status: 401, error: `token rejected: ${e.message}` };
70
+ return { ok: false, status: 401, error: 'token rejected' };
53
71
  }
54
72
  const owner = agent.remote?.webId;
55
73
  if (!owner || webid !== owner) {
@@ -7,10 +7,13 @@ import crypto from 'node:crypto';
7
7
  import webpush from 'web-push';
8
8
 
9
9
  export class Push {
10
- constructor({ store, subject, log = () => {} }) {
10
+ constructor({ store, subject, log = () => {}, liveKeys = null }) {
11
11
  this.store = store;
12
12
  this.subject = subject; // () => the actor URL, VAPID's contact
13
13
  this.log = log;
14
+ // () => the keys of the sign-ins still in force, where they are kept beside
15
+ // the subscriptions; without it no subscription is forgotten for its sign-in.
16
+ this.liveKeys = liveKeys;
14
17
  }
15
18
 
16
19
  state() { return this.store.read('webpush.json', { subs: {} }); }
@@ -68,14 +71,21 @@ export class Push {
68
71
 
69
72
  // Push one notification to every subscription whose alerts allow the type.
70
73
  // A push service answering 404/410 means the browser dropped the
71
- // subscription — it is removed rather than retried forever.
74
+ // subscription — it is removed rather than retried forever. With
75
+ // `liveKeys`, every subscription whose sign-in is no longer in force is
76
+ // forgotten first: an app signed out, expired or pushed off the list hears
77
+ // nothing more.
72
78
  async notify(n, payload) {
73
79
  const st = this.state();
80
+ let dropped = false;
81
+ const live = this.liveKeys?.() || null;
82
+ if (live) {
83
+ for (const key of Object.keys(st.subs)) if (!live.has(key)) { delete st.subs[key]; dropped = true; }
84
+ }
74
85
  const entries = Object.entries(st.subs);
75
- if (!entries.length) return;
86
+ if (!entries.length) { if (dropped) this.save(st); return; }
76
87
  const { publicKey, privateKey } = this.vapid();
77
88
  const details = { subject: this.subject(), publicKey, privateKey };
78
- let dropped = false;
79
89
  for (const [key, sub] of entries) {
80
90
  if (sub.alerts && sub.alerts[n.type] === false) continue;
81
91
  try {
@@ -18,7 +18,7 @@
18
18
  import crypto from 'node:crypto';
19
19
  import { handleDelivery } from './gateway-core.mjs';
20
20
  import { ownerPostAtDoor } from './outbox-door.mjs';
21
- import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
+ import { readCapped, safeFetch, isLoopbackHost, namesPrivateAddress } from '../shared/safefetch.mjs';
22
22
  import * as podRoot from '../pod/root.mjs';
23
23
  import * as podPolicy from '../pod/policy.mjs';
24
24
  import { podBaseOfWebId } from '../pod/urls.mjs';
@@ -26,8 +26,9 @@ import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, clo
26
26
  import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi } from './held-mail.mjs';
27
27
  import { routeNoticesApi } from './notices.mjs';
28
28
  import { withSecurityHeaders } from './headers.mjs';
29
- import { podTokenVerifier } from './caches.mjs';
30
- import { claimedWebId } from './token-claims.mjs';
29
+ import { claimedWebId, verifyPodToken } from './token-claims.mjs';
30
+
31
+ export { verifyPodToken };
31
32
  import { answerPrivateRead } from './private-read.mjs';
32
33
  import { announced, overLimit, relayOne } from './relay-extras.mjs';
33
34
 
@@ -56,29 +57,6 @@ const profilePageOf = (rec) => (rec?.podHome ? rec.podHome + 'ap/profile.html' :
56
57
 
57
58
  const AP_CT = 'application/activity+json';
58
59
 
59
- // Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
60
- // verifier is injected so tests stub it; in production it is the same library
61
- // the agent's own C2S auth uses.
62
- export async function verifyPodToken(request, pathname, verifier) {
63
- const authz = request.headers.get('authorization');
64
- if (!authz) return null;
65
- try {
66
- const dpop = request.headers.get('dpop');
67
- // Solid-OIDC binds the token to a key the client proves on every request;
68
- // a token shown without the proof is one anyone who saw it could show.
69
- if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
70
- const v = verifier || await podTokenVerifier();
71
- const url = request.url;
72
- const { webid } = await v(authz, { header: dpop, method: request.method, url });
73
- return webid || null;
74
- } catch (e) {
75
- // Said aloud: a token the front will not take is otherwise a bare 401 to
76
- // the caller and nothing at all here.
77
- console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
78
- return null;
79
- }
80
- }
81
-
82
60
  // Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
83
61
  // origin — that is the whole proof: a token for a WebID under podHome could
84
62
  // only be minted by someone who controls that pod's identity provider.
@@ -104,6 +82,13 @@ function webidUnderPod(webid, podHome) {
104
82
  } catch { return false; }
105
83
  }
106
84
 
85
+ // Whether a WebID is this row's owner: the one recorded, or for a row from
86
+ // before WebIDs were recorded, one living on the row's pod.
87
+ const ownsRow = (rec) => (webid) => (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome));
88
+
89
+ // A front on this machine: a test run, whose pods and identity providers are here too.
90
+ const frontOnThisMachine = (ctx) => { try { return isLoopbackHost(new URL(ctx.frontOrigin).hostname); } catch { return false; } };
91
+
107
92
  /**
108
93
  * Whether this WebID may speak for this pod.
109
94
  *
@@ -361,7 +346,7 @@ async function route(request, ctx) {
361
346
  // Solid-OIDC token — and must be the WebID the deploy names as admin.
362
347
  if (pathname === '/api/roster') {
363
348
  if (!ctx.listDirectory || !ctx.adminWebId) return j(501, { error: 'this front has no roster to offer' });
364
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
349
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
365
350
  if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
366
351
  if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
367
352
  const rows = await ctx.listDirectory();
@@ -381,7 +366,7 @@ async function route(request, ctx) {
381
366
  // for good; a row seeded in the deploy's environment can only be removed there.
382
367
  if (pathname === '/api/revoke' && request.method === 'POST') {
383
368
  if (!ctx.removeDirectory || !ctx.adminWebId) return j(501, { error: 'this front cannot revoke accounts' });
384
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
369
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
385
370
  if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
386
371
  if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
387
372
  let body;
@@ -470,7 +455,12 @@ async function route(request, ctx) {
470
455
  }
471
456
  const badPod = podHomeProblem(podHome, ctx.frontOrigin);
472
457
  if (badPod) return j(400, { error: badPod });
473
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
458
+ // Anyone may sign up, so the pod and the token's addresses are a
459
+ // stranger's to name, and checking them fetches them. A front on the
460
+ // internet fetches only public ones; one on this machine, for testing, its own.
461
+ const publicOnly = !frontOnThisMachine(ctx);
462
+ if (publicOnly && await namesPrivateAddress(podHome)) return j(400, { error: 'that pod address is not on the public internet' });
463
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { publicOnly });
474
464
  if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
475
465
  if (!await provesPod(webid, podHome, ctx.fetchImpl)) {
476
466
  return j(403, { error: 'the token proves a different pod than the one you listed' });
@@ -571,10 +561,9 @@ async function route(request, ctx) {
571
561
  return withApiCors(j(400, { error: 'movedTo must be an https …/ap/actor URL' }));
572
562
  }
573
563
  if (ctx.frontOrigin && mu.origin === new URL(ctx.frontOrigin).origin) return withApiCors(j(400, { error: 'that address is at this gateway' }));
574
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
564
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: ownsRow(rec) });
575
565
  if (!webid) return withApiCors(j(401, { error: 'a Solid-OIDC token proving the pod is required' }));
576
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
577
- if (!owner) return withApiCors(j(403, { error: "the token proves a different pod than this account's" }));
566
+ if (!ownsRow(rec)(webid)) return withApiCors(j(403, { error: "the token proves a different pod than this account's" }));
578
567
  const movedAt = rec.movedTo === movedTo && rec.movedAt ? rec.movedAt : new Date().toISOString();
579
568
  await ctx.putDirectory(handle, { ...rec, movedTo, movedAt });
580
569
  console.log(`front @${handle}: moved to ${movedTo}`);
@@ -603,10 +592,9 @@ async function route(request, ctx) {
603
592
  const handle = String(body.handle || '').toLowerCase();
604
593
  const rec = await ctx.lookup(handle);
605
594
  if (!rec) { console.log(`relay: no account named ${handle}`); return withApiCors(j(404, { error: 'no such account' })); }
606
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
595
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: ownsRow(rec) });
607
596
  if (!webid) { console.log(`relay @${handle}: no usable pod token`); return withApiCors(j(401, { error: 'a Solid-OIDC token proving the pod is required' })); }
608
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
609
- if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
597
+ if (!ownsRow(rec)(webid)) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
610
598
  const items = Array.isArray(body.requests) ? body.requests : [];
611
599
  if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
612
600
  // Acting through the relay is being here. Hourly at most; see noteOpened.
@@ -677,6 +665,16 @@ async function route(request, ctx) {
677
665
  if (/(^|\/)\.internal(\/|$)/u.test(podUrl.pathname) || /(^|\/)\.\.(\/|$)/u.test(podUrl.pathname)) {
678
666
  return j(403, { error: 'that is not a pod address' });
679
667
  }
668
+ // A server that runs identities runs them for its own pods only, and says
669
+ // which those are. Asked before anything is fetched: the checks below read
670
+ // the addresses the request names, and a stranger names them.
671
+ if (ctx.agentControl.serves) {
672
+ if (!await ctx.agentControl.serves(podBase)) return j(403, { error: 'that pod is not on this server' });
673
+ const claimed = claimedWebId(request);
674
+ if (claimed && !await ctx.agentControl.serves(claimed)) {
675
+ return j(403, { error: 'the token is for a WebID on another server' });
676
+ }
677
+ }
680
678
  // The pod's own server names its owner when it can, and where it does that
681
679
  // is the proof. Where it does not, the WebID must live in EXACTLY this pod
682
680
  // — its own pod base equal to podBase, not merely starting with it, which on
@@ -112,7 +112,10 @@ export function asSigned(request, bytes, origin) {
112
112
  }
113
113
 
114
114
  // `origin` is the door's own, the one senders address (see asSigned).
115
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null } = {}) {
115
+ // `admit(verified)`, when given, says whether the inbox has room for this
116
+ // delivery now; one without room is answered 503 and written nowhere, so its
117
+ // sender tries again later. Without it every delivery that passes is written.
118
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null, admit = null } = {}) {
116
119
  // The body, once, as the bytes the sender digested; the text of it is what
117
120
  // is filed and hashed, as before.
118
121
  let bytes, raw;
@@ -157,6 +160,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
157
160
  failed = `signature ${v.why} (${v.method}); host ${request.headers.get('host') || 'none'}${fwd ? ` forwarded-for ${fwd}` : ''}, date ${has('date')}, digest ${has('digest') === 'yes' || has('content-digest') === 'yes' ? 'yes' : 'no'}; ${params.slice(0, 300)}`;
158
161
  }
159
162
 
163
+ if (admit && !admit(v.verified === true)) return { status: 503, reason: 'inbox full — try later' };
164
+
160
165
  // No per-user secret on record = no receipt: the delivery still filters and
161
166
  // forwards, it just reads as unverified at the drain — never a failure.
162
167
  const receipt = ident.hmacSecret
@@ -605,12 +605,14 @@ async function route(request, pathname, ctx, deps, log) {
605
605
 
606
606
  // The pod sign-in, proved: a code for the app.
607
607
  if (pathname === '/api/authorize' && request.method === 'POST') {
608
- const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
609
- if (!webid) return json(401, { error: 'sign in at your pod first' });
610
608
  const body = await request.clone().json().catch(() => ({}));
611
609
  const found = await accountFor(ctx, body.address, host);
612
610
  if (found.error) return json(found.error[0], { error: found.error[1] });
613
611
  const { handle, rec } = found;
612
+ // A token for anyone but this account's owner is refused before it is
613
+ // checked: checking fetches the addresses it names.
614
+ const webid = await deps.verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === rec.webId });
615
+ if (!webid) return json(401, { error: 'sign in at your pod first' });
614
616
  if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
615
617
  if (!usable(ctx, rec)) return json(409, { error: notReady(rec) });
616
618
  if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
@@ -49,7 +49,7 @@ export async function routeNoticesApi(request, pathname, ctx, { j, verifyPodToke
49
49
  }
50
50
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
51
51
  if (!ctx.putNotice || !ctx.deleteNotice || !ctx.adminWebId) return j(501, { error: 'this front keeps no notices' });
52
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
52
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
53
53
  if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
54
54
  if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
55
55
  let body;
@@ -11,10 +11,10 @@ export async function ownerPostAtDoor(request, pathname, ctx, rec, handle, { cor
11
11
  const json = (status, obj, extra = {}) => ({ status,
12
12
  headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
13
13
  body: JSON.stringify(obj) });
14
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
14
+ const isOwner = (w) => (rec.webId ? w === rec.webId : webidUnderPod(w, rec.podHome));
15
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: isOwner });
15
16
  if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
16
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
17
- if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
17
+ if (!isOwner(webid)) return json(403, { error: 'this outbox belongs to its owner alone' });
18
18
  const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
19
19
  const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
20
20
  { podPut: (u, b, ct) => ctx.podPut(handle, u, b, ct), ownerWebId: webid,
@@ -17,6 +17,7 @@ import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
17
17
  import { asSigned } from './gateway-core.mjs';
18
18
  import { senderKeys } from './caches.mjs';
19
19
  import { keptNow } from './copy.mjs';
20
+ import { sameOrigin } from '../core/intake/activity.mjs';
20
21
 
21
22
  const AP_CT = 'application/activity+json';
22
23
  const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
@@ -51,7 +52,12 @@ export async function signedReader(request, { fetchImpl = fetch, origin = null }
51
52
  const v = await verifyHttpSignature(asSigned(request, new Uint8Array(0), origin), {
52
53
  documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
53
54
  });
54
- return v.verified && typeof v.actor === 'string' ? { actor: v.actor, reason: null } : { actor: null, reason: v.reason };
55
+ if (!v.verified || typeof v.actor !== 'string') return { actor: null, reason: v.reason };
56
+ // A key document names its owner, and anyone can publish one naming anyone.
57
+ // Only a key on the owner's own server speaks for them — the drain's rule too
58
+ // (receiptVouchesFor in lib/core/intake/verify.mjs).
59
+ if (!sameOrigin(v.keyId, v.actor)) return { actor: null, reason: 'key-not-on-signer-origin' };
60
+ return { actor: v.actor, reason: null };
55
61
  }
56
62
 
57
63
  // Refused reads are told nothing, and never held at the edge.
@@ -114,10 +114,10 @@ export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
114
114
  // WebID is the row's, or lives on the row's pod for a row from before WebIDs
115
115
  // were recorded.
116
116
  export async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
117
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
117
+ const isOwner = (w) => (rec.webId ? w === rec.webId : webidUnderPod(w, rec.podHome));
118
+ const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: isOwner });
118
119
  if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
119
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
120
- if (!owner) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
120
+ if (!isOwner(webid)) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
121
121
  return { webid };
122
122
  }
123
123
 
@@ -162,8 +162,6 @@ export async function routeStateApi(request, pathname, ctx, deps) {
162
162
 
163
163
  if (pathname === '/api/state/open') {
164
164
  if (request.method !== 'POST') return j(405, { error: 'POST' });
165
- const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
166
- if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
167
165
  let body = {};
168
166
  try { body = JSON.parse((await request.clone().text()) || '{}'); } catch { return j(400, { error: 'bad JSON' }); }
169
167
  // The account: named, or the one browser account kept here for this WebID.
@@ -171,8 +169,14 @@ export async function routeStateApi(request, pathname, ctx, deps) {
171
169
  // Read fresh: FediPod asks this the moment it has turned keeping on, and a
172
170
  // row read a minute earlier would still say the account is not kept.
173
171
  let rec = handle ? await (ctx.lookupFresh || ctx.lookup)(handle) : null;
172
+ // A token for somebody with no account here is refused before it is
173
+ // checked: checking fetches the addresses it names.
174
+ const dir = handle ? {} : (await ctx.listDirectory?.() || {});
175
+ const only = handle ? (w) => !!rec && w === rec.webId : (w) => Object.values(dir).some((r) => r?.webId === w);
176
+ const webid = await deps.verifyPodToken(request, pathname, ctx.verifier, { only });
177
+ if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
174
178
  if (!handle) {
175
- const rows = Object.entries(await ctx.listDirectory?.() || {})
179
+ const rows = Object.entries(dir)
176
180
  .filter(([, r]) => r?.webId === webid && r.openedAt && r.keeper && !r.movedTo && !r.closedAt);
177
181
  if (rows.length > 1) return j(409, { error: 'more than one account here for this sign-in; name one', handles: rows.map(([h]) => h) });
178
182
  if (rows.length === 1) [[handle, rec]] = rows;
@@ -5,12 +5,74 @@
5
5
  // the claim decides nothing but the address. Verifying it here would let
6
6
  // anyone make the front fetch signing keys from a server they name, on every
7
7
  // read.
8
+ //
9
+ // And checking one: verifyPodToken, which the front's routes share.
8
10
 
9
- export function claimedWebId(request) {
11
+ import { podTokenVerifier } from './caches.mjs';
12
+ import { namesPrivateAddress, safeFetch, readCapped } from '../shared/safefetch.mjs';
13
+
14
+ function claimsOf(request) {
10
15
  const token = /^(?:DPoP|Bearer)\s+([\w-]+)\.([\w-]+)\./u.exec(request.headers.get('authorization') || '');
11
16
  if (!token) return null;
17
+ try { return JSON.parse(Buffer.from(token[2], 'base64url').toString('utf8')); } catch { return null; }
18
+ }
19
+
20
+ export function claimedWebId(request) {
21
+ const webid = claimsOf(request)?.webid;
22
+ return typeof webid === 'string' ? webid : null;
23
+ }
24
+
25
+ // Whether checking this token would stay off private addresses: the WebID it
26
+ // names, its identity provider, and the key set that provider names. A token
27
+ // the check cannot read fetches nothing. A provider whose settings cannot be
28
+ // read here, by a fetch that refuses private addresses at every redirect, is
29
+ // refused: the check's own fetch follows redirects anywhere.
30
+ async function fetchesArePublic(request) {
31
+ const c = claimsOf(request);
32
+ if (!c) return true;
33
+ if (typeof c.webid === 'string' && await namesPrivateAddress(c.webid)) return false;
34
+ if (typeof c.iss !== 'string') return true;
35
+ if (await namesPrivateAddress(c.iss)) return false;
36
+ let jwks = null;
37
+ try {
38
+ const res = await safeFetch(`${c.iss.replace(/\/$/u, '')}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } });
39
+ jwks = JSON.parse(await readCapped(res, 64 * 1024))?.jwks_uri;
40
+ } catch { return false; }
41
+ return !(typeof jwks === 'string' && await namesPrivateAddress(jwks));
42
+ }
43
+
44
+ // Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
45
+ // verifier is injected so tests stub it; in production it is the same library
46
+ // the agent's own C2S auth uses.
47
+ // Checking fetches the WebID and the identity provider the token names, and a
48
+ // stranger names them. `only`, when the caller knows whose token it wants,
49
+ // refuses a token naming anyone else before that; `publicOnly`, where any
50
+ // token may arrive, refuses one whose check would reach a private address.
51
+ export async function verifyPodToken(request, pathname, verifier, { only = null, publicOnly = false } = {}) {
52
+ const authz = request.headers.get('authorization');
53
+ if (!authz) return null;
54
+ const claimed = claimedWebId(request);
55
+ if (only && claimed && !only(claimed)) {
56
+ console.log(`front: pod token for somebody else refused unchecked on ${pathname}`);
57
+ return null;
58
+ }
59
+ if (publicOnly && !await fetchesArePublic(request)) {
60
+ console.log(`front: pod token naming a private address refused unchecked on ${pathname}`);
61
+ return null;
62
+ }
12
63
  try {
13
- const claims = JSON.parse(Buffer.from(token[2], 'base64url').toString('utf8'));
14
- return typeof claims?.webid === 'string' ? claims.webid : null;
15
- } catch { return null; }
64
+ const dpop = request.headers.get('dpop');
65
+ // Solid-OIDC binds the token to a key the client proves on every request;
66
+ // a token shown without the proof is one anyone who saw it could show.
67
+ if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
68
+ const v = verifier || await podTokenVerifier();
69
+ const url = request.url;
70
+ const { webid } = await v(authz, { header: dpop, method: request.method, url });
71
+ return webid || null;
72
+ } catch (e) {
73
+ // Said aloud: a token the front will not take is otherwise a bare 401 to
74
+ // the caller and nothing at all here.
75
+ console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
76
+ return null;
77
+ }
16
78
  }
@@ -241,7 +241,7 @@ export async function ensureInboxDoor(agent, urls, log = () => {}) {
241
241
  * The policy is read from the identity's live state rather than its
242
242
  * published policy document, because both are in this process.
243
243
  */
244
- export async function deliverToInbox(agent, request, { podPut, gatewayWebId = null, fetchImpl = fetch } = {}) {
244
+ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = null, fetchImpl = fetch, admit = null } = {}) {
245
245
  const cfg = agent.store.getConfig() || {};
246
246
  const contacts = agent.store.getContacts();
247
247
  const bl = agent.store.getBlocklist();
@@ -258,7 +258,7 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
258
258
  gatewayWebId,
259
259
  hmacSecret: cfg.gateway?.hmacSecret || null,
260
260
  };
261
- return handleDelivery(request, ident, { podPut, fetchImpl });
261
+ return handleDelivery(request, ident, { podPut, fetchImpl, admit });
262
262
  }
263
263
 
264
264
  /**
@@ -122,6 +122,17 @@ export function insecureUrlReason(url, what = 'address') {
122
122
  + 'Use https:// — or a loopback address if the pod is on this machine.';
123
123
  }
124
124
 
125
+ // Whether a URL's host is, or resolves to, a private address. A host that does
126
+ // not resolve names no address at all, and a request to it fails on its own.
127
+ export async function namesPrivateAddress(url) {
128
+ if (process.env.AP_ALLOW_PRIVATE_TARGETS === '1') return false;
129
+ let u;
130
+ try { u = new URL(url); } catch { return false; }
131
+ const host = u.hostname.replace(/^\[|\]$/g, '');
132
+ if (net.isIP(host)) return isPrivateAddress(host);
133
+ try { return (await dns.lookup(host, { all: true })).some((a) => isPrivateAddress(a.address)); } catch { return false; }
134
+ }
135
+
125
136
  // Throws when the URL must not be requested; otherwise returns the
126
137
  // validated address to pin the connection to ({address, family}), or null
127
138
  // when the URL already names a literal IP.
@@ -163,7 +163,8 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
163
163
  // answer a foreign Origin, per above.
164
164
  const bad = checkRequest(req, allowed, { ignoreOrigin: apiPath });
165
165
  if (bad) {
166
- log(`refused: ${bad} (${req.method} ${req.url})`);
166
+ // The path only: an address can carry a key, and a log is kept.
167
+ log(`refused: ${bad} (${req.method} ${url.pathname})`);
167
168
  res.writeHead(403, { 'content-type': 'text/plain', ...securityHeaders(allowed, false) });
168
169
  res.end('forbidden\n');
169
170
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.48.1",
3
+ "version": "1.49.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "install-service": "node bin/fedipod.mjs install-service",
56
56
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
57
57
  "prepublishOnly": "npm test",
58
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
58
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
59
59
  "verify-ui": "node scripts/verify-ui.mjs",
60
60
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
61
61
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
@@ -8,15 +8,26 @@ const $ = (id) => document.getElementById(id);
8
8
  const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
9
9
  const webId = $('webid').href;
10
10
  const here = location.origin + location.pathname;
11
+ const params = new URLSearchParams(location.search);
11
12
  const login = fediLogin({ dbName: 'fedipod-signin', clientName: 'FediPod sign-in', redirectUri: here });
13
+ // The owner's own yes to this client, given by pressing Allow in this tab. It
14
+ // rides the trip to the pod and back, and nothing else stands for it: a link
15
+ // that lands here with a pod sign-in already stored gets nothing without it.
16
+ // The same rule as the Gateway's app sign-in (web/app-signin/app-signin.mjs).
17
+ const CONSENT = 'fedipod-signin-consent';
18
+ const request = `${params.get('client_id')}\n${params.get('redirect_uri') || ''}`;
19
+ const consented = () => { try { return sessionStorage.getItem(CONSENT) === request; } catch { return false; } };
20
+ const consent = (on) => { try { if (on) sessionStorage.setItem(CONSENT, request); else sessionStorage.removeItem(CONSENT); } catch { /* keeps no site data */ } };
12
21
 
13
22
  // Back from the pod: this finishes the sign-in and returns to the client's
14
23
  // request, which carries the client's own parameters.
15
24
  try { await login.resume(); } catch (e) { say(e.message, true); }
16
25
 
26
+ // Proves the pod sign-in to the account. True when nothing more is asked here.
17
27
  async function prove() {
18
28
  const s = await login.getSession();
19
- if (!s) return false;
29
+ if (!s || !consented()) return false;
30
+ consent(false);
20
31
  if (s.webId !== webId) { say(`You signed in as ${s.webId}, but this account belongs to ${webId}. Sign out there and sign in as the owner.`, true); return true; }
21
32
  say('Signing you in…');
22
33
  const res = await s.fetch(here, {
@@ -30,8 +41,20 @@ async function prove() {
30
41
  return true;
31
42
  }
32
43
 
33
- if (location.search.includes('client_id=') && !(await prove())) $('signin').hidden = false;
44
+ const choices = (shown) => { $('signin').hidden = !shown; $('cancel').hidden = !shown; };
45
+
34
46
  $('signin').addEventListener('click', async () => {
47
+ consent(true);
48
+ choices(false);
49
+ if (await prove()) return;
35
50
  say('Taking you to your pod…');
36
- try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
51
+ try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); choices(true); }
52
+ });
53
+ $('cancel').addEventListener('click', () => {
54
+ consent(false);
55
+ choices(false);
56
+ say('Nothing was allowed. You can close this page.');
37
57
  });
58
+
59
+ // Back from the pod after pressing Allow: finish. Otherwise ask.
60
+ if (params.get('client_id') && !(consented() && await prove())) choices(true);
package/web/front/run.js CHANGED
@@ -186,7 +186,7 @@ async function showReply(res, p) {
186
186
  // back from the server and one comes from sessionStorage, and pasting any
187
187
  // of them into innerHTML means whatever markup they contain is rendered.
188
188
  // Only the fixed wording is markup here; everything variable is text.
189
- const manage = `${p.podBase.replace(/\/$/, '')}${d.doorPath}?dk-token=${encodeURIComponent(d.doorSecret)}`;
189
+ const manage = d.manage || `${p.podBase.replace(/\/$/, '')}${d.doorPath}?dk-token=${encodeURIComponent(d.doorSecret)}`;
190
190
  n.textContent = 'Your Fediverse account is ready. Manage it at ';
191
191
  const a = say('a', p.podBase.replace(/\/$/, '') + d.doorPath); a.href = manage;
192
192
  n.append(a, '.');