Harden the backend's trust surface: an allow-listed public surface, decisions taken on data re-read from the pod
The backend served its settings dataset (including the app actor's private key) to anonymous callers, through the SPARQL endpoint and through the semapps LDP catch-all route, since WebACL is off. An allow-list mounted on every route now exposes only what the app and the pod need (/app, /as/actor/app and its Client ID Document, /catalog, the public key, access needs, the JSON-LD context, nodeinfo, the app inbox and the webhook endpoint); everything else answers 404. Every watcher-fed service (public read, catalog, attendees, contacts, registration) now decides on the activity and object re-read from the pod as the app, checks they belong to the outbox owner and that the stored object has the expected type, and never acts on the notification body. Decisions for one object are serialized and taken on a read made when acting, with a 30 s bound and an abort check before every write. The anonymous read follows the event's visibility both ways, attendees collection included; Festipod records the grants it makes and revokes only those, leaving any other public share untouched; an unreadable ACL changes nothing. A participant's attendance follows their latest act by the pod's timestamp, not the order notifications arrive. Known limits (no conditional write on the pod, a write in flight at the timeout) are documented in the code.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// What the backend may believe about an activity its watcher reports — the check in front of every
|
||||
// Festipod handler fed by that watcher (public-read, catalog, attendees, contacts, registration).
|
||||
//
|
||||
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
|
||||
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
|
||||
// activity or object EMBEDDED in the body is used as written, never re-read (platform behaviour,
|
||||
// @activitypods/app 2.3.2 `pod-activities-watcher.processWebhook`, @semapps/solid listener
|
||||
// `transfer`). Nothing in the body is trusted here except as a pointer: the activity is re-read by
|
||||
// its id from the pod, the object by its id, both through the box owner's access, and the handlers
|
||||
// act only on what the pod returns.
|
||||
//
|
||||
// WHO EMITTED IT. A resource lies in an actor's pod only if that actor (or an app it authorised)
|
||||
// wrote it: the pod refuses to create or modify a resource under another actor's path (see
|
||||
// `actor-pod.js`). So:
|
||||
// - an OUTBOX notification names its emitter: the owner of that outbox (`verifiedEmit`). The
|
||||
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
|
||||
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
|
||||
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
|
||||
// about an actor cannot be stored there.
|
||||
//
|
||||
// An activity is accepted only when all of this holds:
|
||||
// 1. it is identified by a URI (an embedded activity without an id was never stored);
|
||||
// 2. for an outbox notification, that URI is strictly inside the emitter's pod;
|
||||
// 3. the pod returns it at that URI, as a resource with that id;
|
||||
// 4. its stored type is the one the handler expects, and it names exactly one `actor`: the emitter
|
||||
// (outbox), or an actor whose pod holds the activity (inbox);
|
||||
// 5. it names exactly one object, by URI, lying where the handler requires (the emitter's pod for
|
||||
// a resource the emitter creates, updates or deletes; the inbox owner's pod for an activity
|
||||
// about the inbox owner's object; anywhere when the handler checks the location itself);
|
||||
// 6. for a Delete, the pod says the object is gone (404/410) or serves a Tombstone — a live object
|
||||
// means the deletion is not true; otherwise the pod returns the object at that URI, as a
|
||||
// resource with that id, declaring the ActivityStreams type the handler acts on (a Note is not
|
||||
// an Event, whatever the notification says).
|
||||
//
|
||||
// WHAT IT DOES NOT DO: order notifications. Handlers run concurrently, one per webhook; this check
|
||||
// reads the pod BEFORE the handler acts, so on its own an older notification's action can still land
|
||||
// after a newer one's. A handler whose decision depends on the object's current state therefore takes
|
||||
// its own read INSIDE `keyed-serializer.js`, keyed by the object — that, not this check, is what makes
|
||||
// a replayed or reordered notification harmless.
|
||||
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
|
||||
const AS = 'https://www.w3.org/ns/activitystreams#';
|
||||
|
||||
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
/** The URI a value names: a string, or a node's `id`/`@id`; undefined otherwise. */
|
||||
function uriOf(value) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') {
|
||||
const id = value.id || value['@id'];
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The single URI a property holds (one value, a URI or a node with an id); undefined otherwise. */
|
||||
function singleUriOf(value) {
|
||||
const values = listOf(value);
|
||||
return values.length === 1 ? uriOf(values[0]) : undefined;
|
||||
}
|
||||
|
||||
/** The ActivityStreams types a node declares, as full IRIs (`Create`, `as:Create` and the IRI agree). */
|
||||
function asTypesOf(node) {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
return listOf(node.type || node['@type']).map(type => {
|
||||
if (typeof type !== 'string') return undefined;
|
||||
if (type.startsWith(AS)) return type;
|
||||
if (type.startsWith('as:')) return AS + type.slice('as:'.length);
|
||||
return type.includes(':') ? type : AS + type;
|
||||
});
|
||||
}
|
||||
|
||||
const hasAsType = (node, name) => asTypesOf(node).includes(AS + name);
|
||||
|
||||
const ACTIVITY_TYPES = new Set(['Create', 'Update', 'Delete', 'Join', 'Leave']);
|
||||
|
||||
/** `objectOwnerUri` value for a handler that checks where the object lies itself. */
|
||||
const ANY_POD = Symbol('any pod');
|
||||
|
||||
/**
|
||||
* verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) →
|
||||
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
|
||||
* { ok: false, reason }
|
||||
*
|
||||
* `activity` is what the watcher handed over (a URI or a node — only its id is used). `type` is the
|
||||
* activity type the handler expects. `emitterUri` is the owner of the outbox the notification names,
|
||||
* or `null` for an inbox notification (the emitter is then the stored activity's actor). The object
|
||||
* must lie in the pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the
|
||||
* ActivityStreams type the stored object must declare (`Event`, `Profile`; not checked for a Delete).
|
||||
* `read(uri)` reads a resource as the box owner and resolves `{ status, body }` (`status` 0 when the
|
||||
* request itself failed). Never throws for a refusal; a throwing `read` propagates.
|
||||
*/
|
||||
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
|
||||
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
|
||||
if (objectOwnerUri === undefined) throw new Error('objectOwnerUri is required (a URI, or ANY_POD)');
|
||||
if (type !== 'Delete' && !objectType) throw new Error(`objectType is required for a ${type}`);
|
||||
const refuse = reason => ({ ok: false, reason });
|
||||
|
||||
const activityUri = uriOf(activity);
|
||||
if (!activityUri) return refuse('the activity has no id');
|
||||
if (emitterUri !== null && !isInActorPod(activityUri, emitterUri)) {
|
||||
return refuse(`the activity ${activityUri} is not in the pod of ${emitterUri}`);
|
||||
}
|
||||
|
||||
const storedActivity = await read(activityUri);
|
||||
if (storedActivity.status !== 200 || !storedActivity.body) {
|
||||
return refuse(`the pod does not return the activity ${activityUri} (HTTP ${storedActivity.status})`);
|
||||
}
|
||||
const stored = storedActivity.body;
|
||||
if (uriOf(stored) !== activityUri) return refuse(`the pod returned another resource for ${activityUri}`);
|
||||
if (!hasAsType(stored, type)) return refuse(`the stored activity ${activityUri} is not a ${type}`);
|
||||
const storedActor = singleUriOf(stored.actor);
|
||||
let emitter = emitterUri;
|
||||
if (emitter === null) {
|
||||
if (!storedActor || !isInActorPod(activityUri, storedActor)) {
|
||||
return refuse(`the stored activity ${activityUri} is not in the pod of the actor it names`);
|
||||
}
|
||||
emitter = storedActor;
|
||||
} else if (storedActor !== emitter) {
|
||||
return refuse(`the stored activity ${activityUri} is not attributed to ${emitter}`);
|
||||
}
|
||||
|
||||
const objectUri = singleUriOf(stored.object);
|
||||
if (!objectUri) return refuse(`the stored activity ${activityUri} has no single object id`);
|
||||
if (objectOwnerUri !== ANY_POD && !isInActorPod(objectUri, objectOwnerUri)) {
|
||||
return refuse(`the object ${objectUri} is not in the pod of ${objectOwnerUri}`);
|
||||
}
|
||||
|
||||
const storedObject = await read(objectUri);
|
||||
if (type === 'Delete') {
|
||||
const gone = storedObject.status === 404 || storedObject.status === 410;
|
||||
const tombstone = storedObject.status === 200 && hasAsType(storedObject.body, 'Tombstone');
|
||||
if (!gone && !tombstone) return refuse(`the object ${objectUri} is not deleted (HTTP ${storedObject.status})`);
|
||||
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: undefined };
|
||||
}
|
||||
if (storedObject.status !== 200 || !storedObject.body) {
|
||||
return refuse(`the pod does not return the object ${objectUri} (HTTP ${storedObject.status})`);
|
||||
}
|
||||
if (uriOf(storedObject.body) !== objectUri) return refuse(`the pod returned another resource for ${objectUri}`);
|
||||
if (!hasAsType(storedObject.body, objectType)) return refuse(`the stored object ${objectUri} is not a ${objectType}`);
|
||||
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: storedObject.body };
|
||||
}
|
||||
|
||||
/**
|
||||
* The outbox case: an activity `actorUri` emitted about an object in its own pod (a resource it
|
||||
* created, updated or deleted).
|
||||
*/
|
||||
function verifyEmittedActivity({ activity, actorUri, type, objectType, read }) {
|
||||
return verifyActivity({ activity, type, emitterUri: actorUri, objectOwnerUri: actorUri, objectType, read });
|
||||
}
|
||||
|
||||
/** `read` for the checks: a GET through the platform's `pod-resources.get`, as the box owner. */
|
||||
function podReader(ctx, actorUri) {
|
||||
return async uri => {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
|
||||
return { status: typeof status === 'number' ? status : 0, body };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that never throws: on error it logs and reports no match. The watcher runs every
|
||||
* service's matchers for an activity in sequence without catching, inside a webhook call the
|
||||
* platform does not await — a throw would skip the handlers after this one and end the process.
|
||||
* The match itself runs on the webhook's (untrusted) data; it only routes, the verified handler decides.
|
||||
*/
|
||||
function neverThrowingMatcher(match) {
|
||||
return async function (activity, fetcher) {
|
||||
try {
|
||||
return await match.call(this, activity, fetcher);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not match activity ${uriOf(activity)}: ${e.message}`);
|
||||
return { match: false, dereferencedActivity: activity };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `handle` (bound to the service) on an accepted verdict; logs a refusal or a failure, never throws. */
|
||||
function verifiedHandler(box, type, handle, verify) {
|
||||
return async function (ctx, activity, actorUri) {
|
||||
try {
|
||||
const verdict = await verify(activity, actorUri, podReader(ctx, actorUri));
|
||||
if (!verdict.ok) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} reported in the ${box} of ${actorUri}: ${verdict.reason}`);
|
||||
return;
|
||||
}
|
||||
await handle.call(this, ctx, verdict, actorUri);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not process a ${type} reported in the ${box} of ${actorUri}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onEmit` (outbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
|
||||
* `verifyActivity` accepts as emitted by `actorUri`, the outbox owner. `objectType` is the type the
|
||||
* stored object must declare; `objectIn` is `'emitter'` (default: the object is in the emitter's pod)
|
||||
* or `'any'` (the handler checks where the object lies, e.g. the event a Join names).
|
||||
*/
|
||||
function verifiedEmit(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
||||
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
||||
return verifiedHandler('outbox', type, handle, (activity, actorUri, read) =>
|
||||
verifyActivity({
|
||||
activity,
|
||||
type,
|
||||
emitterUri: actorUri,
|
||||
objectOwnerUri: objectIn === 'any' ? ANY_POD : actorUri,
|
||||
objectType,
|
||||
read
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onReceive` (inbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
|
||||
* `verifyActivity` accepts as emitted by the actor it names, about an object in the pod of
|
||||
* `actorUri`, the inbox owner. `verdict.emitterUri` is that emitter.
|
||||
*/
|
||||
function verifiedReceive(type, handle, { objectType } = {}) {
|
||||
return verifiedHandler('inbox', type, handle, (activity, actorUri, read) =>
|
||||
verifyActivity({ activity, type, emitterUri: null, objectOwnerUri: actorUri, objectType, read })
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyActivity,
|
||||
verifyEmittedActivity,
|
||||
podReader,
|
||||
neverThrowingMatcher,
|
||||
verifiedEmit,
|
||||
verifiedReceive,
|
||||
uriOf,
|
||||
singleUriOf,
|
||||
hasAsType,
|
||||
ANY_POD
|
||||
};
|
||||
Reference in New Issue
Block a user