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.
- package/lib/client/masto/accounts.mjs +18 -4
- package/lib/client/masto/index.mjs +1 -0
- package/lib/client/masto/oauth.mjs +62 -4
- package/lib/client/oidc-auth.mjs +20 -2
- package/lib/client/webpush.mjs +14 -4
- 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 +2 -2
- package/lib/shared/safefetch.mjs +11 -0
- package/lib/surface/surface.mjs +2 -1
- package/package.json +2 -2
- package/web/admin/oauth-signin.mjs +26 -3
- package/web/front/run.js +1 -1
|
@@ -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
|
|
43
|
-
|
|
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,
|
|
61
|
+
await podMedia.write(api.agent.remote, url, f.data, type);
|
|
48
62
|
return url;
|
|
49
63
|
};
|
|
50
64
|
|
|
@@ -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:#
|
|
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
|
|
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
|
-
|
|
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, {});
|
package/lib/client/oidc-auth.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
70
|
+
return { ok: false, status: 401, error: 'token rejected' };
|
|
53
71
|
}
|
|
54
72
|
const owner = agent.remote?.webId;
|
|
55
73
|
if (!owner || webid !== owner) {
|
package/lib/client/webpush.mjs
CHANGED
|
@@ -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 {
|
|
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
|
/**
|
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.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
|
-
|
|
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, '.');
|