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:
Sylvain Duchesne
2026-10-13 16:30:52 +02:00
parent d38f8a7384
commit 88f23b7023
21 changed files with 3362 additions and 243 deletions
@@ -0,0 +1,127 @@
const { test, expect, describe } = require('bun:test');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const attendees = require('./attendees.service');
const contacts = require('./contacts.service');
const ALICE = 'http://localhost:3000/alice';
const MALLORY = 'http://localhost:3000/mallory';
const E = `${ALICE}/data/events/e1`;
const C = `${ALICE}/data/e1-attendees`;
const PROFILE = `${ALICE}/data/profiles/me`;
const CREATE = `${ALICE}/data/activities/create-1`;
/** A schema bound like moleculer binds it: mixin methods first, the service's own over them. */
function bind(schema, pod, { attached, groupFails = false } = {}) {
const logs = { info: [], warn: [], error: [] };
const calls = [];
const ctx = {
call: async (action, params) => {
calls.push({ action, params });
if (action === 'pod-resources.get') {
const body = pod[params.resourceUri];
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
}
if (action === 'pod-collections.createAndAttach') return attached;
if (action === 'pod-wac-groups.create' && groupFails) throw new Error('group creation failed');
return undefined;
}
};
const svc = {
name: schema.name,
settings: { ...PodCollectionsHandlerMixin.settings, ...schema.settings },
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
actions: {}
};
for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...(schema.methods || {}) })) svc[name] = fn.bind(svc);
const actions = { ...PodCollectionsHandlerMixin.actions, ...schema.actions };
for (const [name, def] of Object.entries(actions)) {
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
}
const emit = (key, hint, actorUri) => schema.activities[key].onEmit.call(svc, ctx, hint, actorUri);
return { svc, ctx, calls, logs, emit };
}
const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object: objectUri });
const actions = calls => calls.map(c => c.action);
describe('attendees — a verified Create attaches the collection, then aligns its public read with the event', () => {
test('a public event: collection attached, group created, then public-read.reconcile on the event', async () => {
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', to: 'as:Public' } }, { attached: C });
await emit('create', created(E), ALICE);
expect(actions(calls).filter(a => a !== 'pod-resources.get')).toEqual([
'pod-collections.createAndAttach',
'public-read.recordCreated',
'pod-wac-groups.create',
'public-read.reconcile'
]);
expect(calls.find(c => c.action === 'public-read.recordCreated').params).toEqual({ resourceUri: C, actorUri: ALICE });
expect(calls.find(c => c.action === 'public-read.reconcile').params).toEqual({ resourceUri: E, actorUri: ALICE });
});
test('the collection is recorded as Festipod\'s even when the WAC group creation after the attach fails', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, groupFails: true });
await emit('create', created(E), ALICE);
const order = actions(calls).filter(a => a !== 'pod-resources.get');
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'pod-wac-groups.create', 'public-read.reconcile']);
expect(logs.error.some(m => m.includes('group creation failed'))).toBe(true);
});
test('a PRIVATE event: its collection is reconciled too (the pod made it world-readable; it must not stay so)', async () => {
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C });
await emit('create', created(E), ALICE);
expect(calls.some(c => c.action === 'public-read.reconcile' && c.params.resourceUri === E)).toBe(true);
expect(calls.some(c => c.action === 'public-read.grant')).toBe(false);
});
test('nothing attached (already carries a collection): no WAC group, no throw, still reconciled', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', 'apods:attendees': C } }, { attached: undefined });
await emit('create', created(E), ALICE);
expect(actions(calls)).not.toContain('pod-wac-groups.create');
expect(actions(calls)).not.toContain('public-read.recordCreated'); // not Festipod's collection: never revoked
expect(actions(calls)).toContain('public-read.reconcile');
expect(logs.error).toEqual([]);
});
test("a forged Create naming another actor's event, or a stored Note, touches nothing", async () => {
const malloryEvent = `${MALLORY}/data/events/e`;
let w = bind(attendees, {}, { attached: C });
await w.emit('create', { type: 'Create', actor: ALICE, object: { id: malloryEvent, type: 'Event' } }, ALICE);
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
w = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Note', to: 'as:Public' } }, { attached: C });
await w.emit('create', created(E), ALICE);
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
});
});
describe('contacts — the webhook is a hint, and nothing attached creates no group', () => {
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
test('the normal case: the collection is attached to the STORED profile, for the outbox owner', async () => {
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
// The body claims Mallory created it: the outbox owner (Alice), re-read from the pod, is who counts.
await emit('create', { ...created(PROFILE), actor: MALLORY }, ALICE);
const attach = calls.find(c => c.action === 'pod-collections.createAndAttach');
expect(attach.params).toMatchObject({ resourceUri: PROFILE, actorUri: ALICE });
expect(actions(calls)).toContain('pod-wac-groups.create');
});
test('a Create the pod does not store attaches nothing', async () => {
const { calls, emit } = bind(contacts, { [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
await emit('create', { type: 'Create', actor: ALICE, object: profile }, ALICE);
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
});
test('a stored object that is not a Profile attaches nothing', async () => {
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: { id: PROFILE, type: 'Event' } }, { attached: `${ALICE}/data/contacts` });
await emit('create', created(PROFILE), ALICE);
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
});
test('nothing attached (the profile already carries contacts): no WAC group, and nothing thrown', async () => {
const { calls, emit, logs } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: undefined });
await expect(emit('create', created(PROFILE), ALICE)).resolves.toBeUndefined();
expect(actions(calls)).toContain('pod-collections.createAndAttach');
expect(actions(calls)).not.toContain('pod-wac-groups.create');
expect(logs.error).toEqual([]);
});
});