fedipod 1.48.1 → 1.49.1

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.
@@ -435,6 +435,15 @@ export class PodStore {
435
435
  // arrival order — the Mastodon-API facade serves timelines from this.
436
436
  // [{ noteId, actor, content, published, inReplyTo, kind: 'timeline'|'post'|'tag' }]
437
437
  getStatuses() { return this.read('statuses.json', []); }
438
+ // When each open poll closes, read in place: asked every 30 seconds, where
439
+ // a copy of the whole timeline each time was the whole cost of asking.
440
+ openPollEnds() {
441
+ const out = [];
442
+ for (const s of this.cache.get('statuses.json') || []) {
443
+ if (s?.kind === 'post' && s.poll && !s.poll.closed && s.poll.expiresAt) out.push(Date.parse(s.poll.expiresAt));
444
+ }
445
+ return out;
446
+ }
438
447
  // A count must not pay for a clone: nodeinfo and the instance document are
439
448
  // public and answer strangers, so they read the cache directly.
440
449
  countStatuses(kind = null) {
@@ -482,6 +491,11 @@ export class PodStore {
482
491
  const all = this.getStatuses();
483
492
  const i = all.findIndex(x => x.noteId === noteId);
484
493
  if (i < 0) return null;
494
+ // An edit is held to the size a new post is: a remote Update could
495
+ // otherwise put a post of megabytes into the document rewritten on every change.
496
+ if (typeof patch?.content === 'string' && patch.content.length > MAX_CONTENT) {
497
+ patch = { ...patch, content: clamp(patch.content, MAX_CONTENT), truncated: true };
498
+ }
485
499
  all[i] = { ...all[i], ...patch };
486
500
  this.write('statuses.json', all);
487
501
  return all[i];
@@ -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
  /**
@@ -304,6 +304,7 @@ export async function startEmbeddedAgent({
304
304
  autoAcceptFollows = true,
305
305
  gateToken = null,
306
306
  uiPath = '/fp/',
307
+ watch = null,
307
308
  }) {
308
309
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
309
310
  const handle = handleFor(base);
@@ -390,6 +391,7 @@ export async function startEmbeddedAgent({
390
391
  gate: makeGate(gateToken, {
391
392
  secureCookie: authorities.secure,
392
393
  cookiePath: mount ? mount + uiPath : '/',
394
+ failClosed: true,
393
395
  }),
394
396
  allowed: authorities,
395
397
  embedded: true,
@@ -402,9 +404,12 @@ export async function startEmbeddedAgent({
402
404
  // A delivery landing in the inbox — through the door, or from any other
403
405
  // writer — wakes the drain at once. Only additions, and only in the inbox:
404
406
  // the drain's own DELETEs must not call it back.
407
+ // `watch(inbox, handler)`, from a server running many identities, is one
408
+ // listener for all of them; without it each listens to the store itself.
405
409
  let coalesce = null;
406
410
  let onChanged = null;
407
- if (typeof resourceStore?.on === 'function') {
411
+ let unwatch = null;
412
+ if (typeof watch === 'function' || typeof resourceStore?.on === 'function') {
408
413
  const inbox = urls.inbox;
409
414
  onChanged = (identifier, activity) => {
410
415
  const kind = String(activity?.value ?? activity ?? '');
@@ -417,16 +422,19 @@ export async function startEmbeddedAgent({
417
422
  }, DRAIN_COALESCE_MS);
418
423
  coalesce.unref?.();
419
424
  };
420
- resourceStore.on('changed', onChanged);
425
+ if (typeof watch === 'function') unwatch = watch(inbox, onChanged);
426
+ else resourceStore.on('changed', onChanged);
421
427
  }
422
428
 
423
429
  const stop = async () => {
424
- if (onChanged) resourceStore.off('changed', onChanged);
430
+ if (unwatch) unwatch();
431
+ else if (onChanged) resourceStore.off('changed', onChanged);
425
432
  surface.streaming?.stop?.();
426
433
  clearTimeout(coalesce);
427
434
  agent.intake?.stop();
428
435
  agent.tagfeed?.stop();
429
436
  agent.bskyfeed?.stop();
437
+ agent.acctfeed?.stop();
430
438
  agent.deliverer?.stop();
431
439
  agent.importer?.stop();
432
440
  clearInterval(agent.schedTimer);
@@ -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.1",
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 claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.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",
package/vendor/gate.cjs CHANGED
@@ -88,11 +88,18 @@ function cookieValue(header, name) {
88
88
  // AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
89
89
  // `token` may be a function, resolved per request: an identity's secret can
90
90
  // rotate while the server runs, and the very next request sees the new one.
91
- function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/' } = {}) {
91
+ // `failClosed`: with no token, refuse rather than open — for a gate whose
92
+ // token is only ever missing by accident (an account on a pod server).
93
+ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/', failClosed = false } = {}) {
92
94
  const tokenNow = () => (typeof token === 'function' ? token() : token);
93
95
  // gate(req, res) → true when the gate handled the response (caller stops).
94
96
  function gate(req, res) {
95
97
  const t = tokenNow();
98
+ if (!t && failClosed) {
99
+ res.writeHead(401, { 'content-type': 'text/plain' });
100
+ res.end('fedipod: this account has no key to open its pages with\n');
101
+ return true;
102
+ }
96
103
  if (!t) return false;
97
104
  if (tokenOk(req.headers[HEADER], t)) return false;
98
105
  if (tokenOk(cookieValue(req.headers.cookie, COOKIE), t)) return false;
@@ -129,7 +136,8 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
129
136
  // Websocket upgrades have no res to answer with — just allowed or not.
130
137
  gate.upgradeOk = (req) => {
131
138
  const t = tokenNow();
132
- return !t || tokenOk(req.headers[HEADER], t) || tokenOk(cookieValue(req.headers.cookie, COOKIE), t);
139
+ if (!t) return !failClosed;
140
+ return tokenOk(req.headers[HEADER], t) || tokenOk(cookieValue(req.headers.cookie, COOKIE), t);
133
141
  };
134
142
 
135
143
  return gate;
@@ -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, '.');