Files
festipod/backend/services/emitted-activity.test.js
T
Sylvain Duchesne 88f23b7023 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.
2026-10-13 16:30:52 +02:00

267 lines
15 KiB
JavaScript

const { test, expect, describe } = require('bun:test');
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
// The activity and object URIs a genuine Festipod creation produces: both under the actor's pod.
const ACT = `${ALICE}/outbox/act-1`;
const OBJ = `${ALICE}/data/events/e1`;
/** A `read(uri)` built from a map of uri → { status, body }; a missing uri answers 404. */
const reader = pod => async uri => pod[uri] || { status: 404, body: undefined };
const createActivity = { id: ACT, type: 'Create', actor: ALICE, object: OBJ };
const event = (extra = {}) => ({ id: OBJ, type: 'Event', name: 'Concert', ...extra });
describe('verifyEmittedActivity — the webhook is a hint; the pod is the truth', () => {
test('the normal case: a Create emitted by the actor, re-read, is accepted with the STORED object', async () => {
const stored = event({ to: 'as:Public', name: 'the real, stored name' });
const read = reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: stored } });
// The webhook copy carries a DIFFERENT, attacker-chosen object body — it must be ignored.
const webhookActivity = { id: ACT, type: 'Create', actor: ALICE, object: { id: OBJ, type: 'Event', to: 'as:Public', name: 'forged' } };
const r = await verifyEmittedActivity({ activity: webhookActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(true);
expect(r.objectUri).toBe(OBJ);
expect(r.object.name).toBe('the real, stored name');
});
test('the activity is re-read even when the webhook embeds a full activity node', async () => {
const calls = [];
const read = async uri => {
calls.push(uri);
return uri === ACT ? { status: 200, body: createActivity } : { status: 200, body: event() };
};
const embedded = { id: ACT, type: 'Create', actor: ALICE, object: event() };
const r = await verifyEmittedActivity({ activity: embedded, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(true);
expect(calls).toEqual([ACT, OBJ]); // both re-read from the pod, nothing taken from the body
});
test('FORGERY: a victim\'s private event named through the attacker\'s outbox is refused', async () => {
// The attacker (Alice) owns her webhook URL; she forges a Create naming her own outbox as
// target and a VICTIM\'s private event as the object, addressed to Public. The watcher resolves
// actorUri = Alice (her outbox). The embedded object claims BOB\'s event is public.
const victimEvent = `${BOB}/data/events/private`;
const forged = { id: `${ALICE}/outbox/forged`, type: 'Create', actor: ALICE, object: { id: victimEvent, type: 'Event', to: 'as:Public' } };
// Even if the pod happens to return the forged activity (it is in Alice\'s pod), its object is
// in BOB\'s pod → refused at the object-ownership check, before any read of BOB\'s event.
const read = reader({ [`${ALICE}/outbox/forged`]: { status: 200, body: forged } });
const r = await verifyEmittedActivity({ activity: forged, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not in the pod of');
});
test('a Create the pod does not actually store (only in the webhook body) is refused', async () => {
const read = reader({}); // the pod returns 404 for the activity
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('does not return the activity');
});
test('a stored activity attributed to someone else is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Create', actor: BOB, object: OBJ } } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not attributed to');
});
test('a stored activity of the wrong type is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Announce', actor: ALICE, object: OBJ } } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('is not a Create');
});
test('an activity with no id (never stored) is refused before any read', async () => {
let reads = 0;
const read = async () => { reads++; return { status: 404 }; };
const r = await verifyEmittedActivity({ activity: { type: 'Create', actor: ALICE, object: event() }, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('no id');
expect(reads).toBe(0);
});
test('a Delete is accepted only when the pod says the object is gone or a Tombstone', async () => {
const del = { id: `${ALICE}/outbox/del`, type: 'Delete', actor: ALICE, object: OBJ };
const act = { [`${ALICE}/outbox/del`]: { status: 200, body: del } };
// Object gone → accepted.
let r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 404 } }) });
expect(r.ok).toBe(true);
expect(r.objectUri).toBe(OBJ);
// Tombstone → accepted.
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: { id: OBJ, type: 'Tombstone' } } }) });
expect(r.ok).toBe(true);
// Object still live → refused (the deletion is not true).
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: event() } }) });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not deleted');
});
});
describe('verifiedEmit — runs the handler only on an accepted activity, never throws', () => {
function svc(pod) {
const logs = { info: [], warn: [], error: [] };
const ran = [];
const service = {
name: 'test',
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
};
const ctx = { call: async (action, { resourceUri }) => (pod[resourceUri] || { status: 404 }) };
const onEmit = verifiedEmit('Create', async function (_ctx, verdict, actorUri) {
ran.push({ objectUri: verdict.objectUri, actorUri });
}, { objectType: 'Event' }).bind(service);
return { onEmit, ctx, logs, ran };
}
test('an accepted Create runs the handler with the stored object and the watcher\'s actor', async () => {
const { onEmit, ctx, ran } = svc({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: event() } });
await onEmit(ctx, createActivity, ALICE);
expect(ran).toEqual([{ objectUri: OBJ, actorUri: ALICE }]);
});
test('a refused Create logs a warning and does NOT run the handler', async () => {
const { onEmit, ctx, logs, ran } = svc({}); // pod returns 404 for the activity
await onEmit(ctx, createActivity, ALICE);
expect(ran).toEqual([]);
expect(logs.warn).toHaveLength(1);
expect(logs.warn[0]).toContain('ignored a Create');
});
test('a throwing read is caught and logged, never propagated', async () => {
const service = { name: 'test', logger: { info() {}, warn() {}, error(m) { this._e = m; } } };
const onEmit = verifiedEmit('Create', async () => {}, { objectType: 'Event' }).bind(service);
const ctx = { call: async () => { throw new Error('proxy down'); } };
await expect(onEmit(ctx, createActivity, ALICE)).resolves.toBeUndefined();
expect(service.logger._e).toContain('proxy down');
});
});
describe('neverThrowingMatcher', () => {
test('a throwing matcher reports no match and logs', async () => {
const service = { name: 'test', logger: { error(m) { this._e = m; } } };
const wrapped = neverThrowingMatcher(async () => { throw new Error('boom'); }).bind(service);
const r = await wrapped({ id: ACT }, async () => false);
expect(r.match).toBe(false);
expect(service.logger._e).toContain('boom');
});
});
describe('the stored object must be of the type the handler acts on', () => {
test('a public Note re-read at the object URI is refused for an Event handler; an Event is accepted', async () => {
const note = { id: OBJ, type: 'Note', to: 'as:Public', content: 'not an event' };
let r = await verifyEmittedActivity({
activity: createActivity,
actorUri: ALICE,
type: 'Create',
objectType: 'Event',
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: note } })
});
expect(r.ok).toBe(false);
expect(r.reason).toContain('is not a Event');
for (const type of ['Event', 'as:Event', 'https://www.w3.org/ns/activitystreams#Event', ['Event', 'apods:MeetingPoint']]) {
r = await verifyEmittedActivity({
activity: createActivity,
actorUri: ALICE,
type: 'Create',
objectType: 'Event',
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: { id: OBJ, type } } })
});
expect(r.ok).toBe(true);
}
});
test('a Profile handler refuses an Event and accepts a Profile', async () => {
const pod = body => reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body } });
const asProfile = { activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Profile' };
expect((await verifyEmittedActivity({ ...asProfile, read: pod(event()) })).ok).toBe(false);
expect((await verifyEmittedActivity({ ...asProfile, read: pod({ id: OBJ, type: ['as:Profile', 'vcard:Individual'] }) })).ok).toBe(true);
});
test('a type is required for anything but a Delete (a forgotten check is a programming error)', async () => {
await expect(verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', read: reader({}) })).rejects.toThrow('objectType is required');
});
test('the pod answering with another resource than the activity asked for is refused', async () => {
const read = reader({ [ACT]: { status: 200, body: { ...createActivity, id: `${ALICE}/outbox/other` } }, [OBJ]: { status: 200, body: event() } });
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('another resource');
});
});
describe('verifyActivity — an activity RECEIVED in an inbox (emitter = the actor whose pod holds it)', () => {
// Bob joins Alice's meeting point: the Join lies in Bob's pod, its object in Alice's pod; the
// notification comes from ALICE's inbox (Alice is the box owner, `actorUri`).
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE };
const meetingPoint = { id: MP, type: 'Event', 'apods:attendees': `${ALICE}/data/collections/mp1-attendees` };
const received = read => verifyActivity({ activity: JOIN, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
test('the normal case: a Join stored in the joiner\'s pod, about the inbox owner\'s meeting point', async () => {
const r = await received(reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(true);
expect(r.emitterUri).toBe(BOB);
expect(r.object).toEqual(meetingPoint);
});
test('an activity naming an actor whose pod does not hold it is refused (Mallory cannot join as Bob)', async () => {
const MALLORY = 'http://localhost:3000/mallory';
const forgedUri = `${MALLORY}/data/activities/join-as-bob`;
const read = reader({ [forgedUri]: { status: 200, body: { ...join, id: forgedUri, actor: BOB } }, [MP]: { status: 200, body: meetingPoint } });
const r = await verifyActivity({ activity: forgedUri, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
expect(r.ok).toBe(false);
expect(r.reason).toContain('not in the pod of the actor it names');
});
test('a Join about an object outside the inbox owner\'s pod is refused (an embedded attendees URI never reaches a write)', async () => {
const elsewhere = `${BOB}/data/events/bobs-mp`;
const read = reader({ [JOIN]: { status: 200, body: { ...join, object: { id: elsewhere, type: 'Event', 'apods:attendees': 'http://localhost:3000/carol/data/c' } } } });
const r = await received(read);
expect(r.ok).toBe(false);
expect(r.reason).toContain(`not in the pod of ${ALICE}`);
});
test('an activity with several actors, or a Leave where a Join is expected, is refused', async () => {
let r = await received(reader({ [JOIN]: { status: 200, body: { ...join, actor: [BOB, ALICE] } }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(false);
r = await received(reader({ [JOIN]: { status: 200, body: { ...join, type: 'Leave' } }, [MP]: { status: 200, body: meetingPoint } }));
expect(r.ok).toBe(false);
});
test('a location rule must be stated: ANY_POD explicitly, never by omission', async () => {
await expect(verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectType: 'Event', read: reader({}) })).rejects.toThrow('objectOwnerUri is required');
const read = reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } });
const r = await verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectOwnerUri: ANY_POD, objectType: 'Event', read });
expect(r.ok).toBe(true);
});
});
describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
const pod = { [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: { id: MP, type: 'Event' } } };
const service = () => ({ name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error(m) { this.e = m; } } });
const ctx = { call: async (action, { resourceUri }) => pod[resourceUri] || { status: 404 } };
test('an inbox Join runs the handler with the emitter read from the pod, not from the webhook body', async () => {
const ran = [];
const onReceive = verifiedReceive('Join', async (_ctx, verdict, actorUri) => ran.push([verdict.emitterUri, verdict.objectUri, actorUri]), { objectType: 'Event' }).bind(service());
// The webhook body claims Carol joined; the stored activity says Bob.
await onReceive(ctx, { id: JOIN, type: 'Join', actor: 'http://localhost:3000/carol', object: MP }, ALICE);
expect(ran).toEqual([[BOB, MP, ALICE]]);
});
test('an outbox Join about an event in another pod passes with objectIn "any", and is refused by default', async () => {
const ran = [];
const handle = async (_ctx, verdict) => ran.push(verdict.objectUri);
await verifiedEmit('Join', handle, { objectType: 'Event', objectIn: 'any' }).call(service(), ctx, JOIN, BOB);
const svc = service();
await verifiedEmit('Join', handle, { objectType: 'Event' }).call(svc, ctx, JOIN, BOB);
expect(ran).toEqual([MP]);
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
});
});