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.
- package/lib/client/masto/accounts.mjs +18 -4
- package/lib/client/masto/index.mjs +2 -0
- package/lib/client/masto/oauth.mjs +103 -25
- package/lib/client/oidc-auth.mjs +20 -2
- package/lib/client/webpush.mjs +14 -4
- package/lib/connections/tagfeed.mjs +28 -8
- package/lib/core/agent.mjs +16 -4
- package/lib/core/deliver.mjs +8 -2
- package/lib/core/intake/index.mjs +10 -5
- package/lib/core/publisher/index.mjs +3 -0
- package/lib/core/publisher/notes.mjs +5 -1
- package/lib/core/scheduled.mjs +12 -0
- package/lib/core/store.mjs +14 -0
- package/lib/gateway/front-core.mjs +33 -35
- package/lib/gateway/gateway-core.mjs +6 -1
- package/lib/gateway/masto-gateway.mjs +4 -2
- package/lib/gateway/notices.mjs +1 -1
- package/lib/gateway/outbox-door.mjs +3 -3
- package/lib/gateway/private-read.mjs +7 -1
- package/lib/gateway/quiet.mjs +3 -3
- package/lib/gateway/state-api.mjs +7 -3
- package/lib/gateway/token-claims.mjs +66 -4
- package/lib/server/embed.mjs +13 -5
- package/lib/shared/safefetch.mjs +11 -0
- package/lib/surface/surface.mjs +2 -1
- package/package.json +2 -2
- package/vendor/gate.cjs +10 -2
- package/web/admin/oauth-signin.mjs +26 -3
- package/web/front/run.js +1 -1
package/lib/core/store.mjs
CHANGED
|
@@ -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 {
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}` });
|
package/lib/gateway/notices.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/lib/gateway/quiet.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
}
|
package/lib/server/embed.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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);
|
package/lib/shared/safefetch.mjs
CHANGED
|
@@ -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.
|
package/lib/surface/surface.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '.');
|