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,176 @@
|
||||
const path = require('path');
|
||||
const { test, expect, describe, afterEach } = require('bun:test');
|
||||
const { ServiceBroker } = require('moleculer');
|
||||
const { isPublicRequest, publicSurfaceGate } = require('./public-surface');
|
||||
|
||||
const UUID = 'd9b5f8ca-b2e2-41eb-af83-64e5ef1d4b20';
|
||||
|
||||
describe('the core service mounts the gate in front of every route', () => {
|
||||
test('core.service.js wires publicSurfaceGate as a global gateway middleware, and no SPARQL endpoint', () => {
|
||||
const core = require('./core/core.service.js');
|
||||
expect(core.settings.api.use).toContain(publicSurfaceGate);
|
||||
expect(core.settings.sparqlEndpoint).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicRequest — the allow-list', () => {
|
||||
test('every endpoint a pod or the Festipod front end calls is public', () => {
|
||||
for (const [method, p] of [
|
||||
['GET', '/app'],
|
||||
['GET', '/as/actor/app'],
|
||||
['GET', '/catalog'],
|
||||
['GET', `/public-key/${UUID}`],
|
||||
['HEAD', `/public-key/${UUID}`],
|
||||
['GET', `/interop/access-need-group/${UUID}`],
|
||||
['GET', `/interop/access-need/${UUID}`],
|
||||
['GET', '/.well-known/context.jsonld'],
|
||||
['GET', '/.well-known/nodeinfo'],
|
||||
['GET', '/nodeinfo/2.1'],
|
||||
['POST', '/as/actor/app/inbox'],
|
||||
['POST', `/.webhooks/${UUID}`]
|
||||
]) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} true`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the app's private keys, its registrations and grants, every listing and the SPARQL endpoint are refused", () => {
|
||||
for (const p of [
|
||||
'/key',
|
||||
`/key/${UUID}`,
|
||||
'/', // the root container: lists every container of the settings dataset
|
||||
'/public-key', // container listing
|
||||
'/interop',
|
||||
`/interop/application-registration/${UUID}`,
|
||||
`/interop/access-grant/${UUID}`,
|
||||
`/interop/data-grant/${UUID}`,
|
||||
'/interop/access-need',
|
||||
'/sparql',
|
||||
'/as/actor/app/outbox',
|
||||
'/as/actor/app/inbox', // GET: nobody reads the app's inbox
|
||||
'/as/actor/app/followers',
|
||||
'/as/actor',
|
||||
'/.well-known/webfinger',
|
||||
`/.webhooks/${UUID}`, // GET
|
||||
'/proxy'
|
||||
]) {
|
||||
expect(`GET ${p} ${isPublicRequest('GET', p)}`).toBe(`GET ${p} false`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every write outside the two delivery endpoints is refused', () => {
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
for (const p of ['/app', '/catalog', '/as/actor/app', `/public-key/${UUID}`, `/interop/access-need/${UUID}`, '/key', '/', '/sparql', '/proxy', '/as/actor/app/outbox']) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} false`);
|
||||
}
|
||||
}
|
||||
expect(isPublicRequest('PUT', `/.webhooks/${UUID}`)).toBe(false);
|
||||
expect(isPublicRequest('DELETE', '/as/actor/app/inbox')).toBe(false);
|
||||
});
|
||||
|
||||
test('a path that only resembles a public one is refused (traversal, encoding, extra segments, case)', () => {
|
||||
for (const p of [
|
||||
`/public-key/../key/${UUID}`,
|
||||
'/public-key/..',
|
||||
`/public-key/%2e%2e/key/${UUID}`,
|
||||
`/public-key/${UUID}%2F..%2Fkey`,
|
||||
`/public-key/${UUID}/extra`,
|
||||
`//public-key/${UUID}`,
|
||||
`/Public-Key/${UUID}`,
|
||||
'/app/',
|
||||
'/apps',
|
||||
'/catalog/x',
|
||||
`/interop/access-need/${UUID}/../../key`,
|
||||
'/.well-known/context.jsonldx'
|
||||
]) {
|
||||
expect(`${p} ${isPublicRequest('GET', p)}`).toBe(`${p} false`);
|
||||
}
|
||||
expect(isPublicRequest(undefined, '/app')).toBe(false);
|
||||
expect(isPublicRequest('GET', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicSurfaceGate — the middleware', () => {
|
||||
const run = req => {
|
||||
let passed;
|
||||
publicSurfaceGate(req, {}, err => {
|
||||
passed = err === undefined ? true : err;
|
||||
});
|
||||
return passed;
|
||||
};
|
||||
|
||||
test('passes a public request, answers 404 to any other (before authentication runs)', () => {
|
||||
expect(run({ method: 'GET', parsedUrl: '/catalog', headers: {} })).toBe(true);
|
||||
const refusal = run({ method: 'GET', parsedUrl: '/key', headers: {} });
|
||||
expect(refusal).toBeInstanceOf(Error);
|
||||
expect(refusal.code).toBe(404);
|
||||
});
|
||||
|
||||
test('a CORS preflight is judged on the method it announces', () => {
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'GET' } })).toBe(true);
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'DELETE' } }).code).toBe(404);
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: {} }).code).toBe(404);
|
||||
});
|
||||
|
||||
test('without the gateway-parsed path, the raw URL is used without its query string', () => {
|
||||
expect(run({ method: 'GET', originalUrl: '/catalog?x=1', headers: {} })).toBe(true);
|
||||
expect(run({ method: 'GET', originalUrl: '/key?x=/catalog', headers: {} }).code).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// The mechanism, end to end: a real moleculer-web gateway (the version CoreService uses), with the
|
||||
// REAL @semapps/ldp catch-all route added at runtime as the LDP service adds it, in front of a stand-in
|
||||
// `ldp.api` whose every action answers as if it served the settings dataset. Status codes only.
|
||||
describe('the gate covers routes added at runtime — the LDP catch-all that served the private key', () => {
|
||||
const semappsCore = path.dirname(require.resolve('@semapps/core'));
|
||||
const ApiGateway = require(require.resolve('moleculer-web', { paths: [semappsCore] }));
|
||||
const ldpDir = path.dirname(require.resolve('@semapps/ldp/package.json', { paths: [semappsCore] }));
|
||||
const { default: getCatchAllRoute } = require(path.join(ldpDir, 'dist/routes/getCatchAllRoute.js'));
|
||||
|
||||
let broker;
|
||||
afterEach(async () => {
|
||||
if (broker) await broker.stop();
|
||||
broker = undefined;
|
||||
});
|
||||
|
||||
async function startGateway(use) {
|
||||
broker = new ServiceBroker({ logger: false });
|
||||
const answer = () => 'settings dataset content';
|
||||
broker.createService({
|
||||
name: 'ldp.api',
|
||||
actions: { get: answer, head: answer, post: answer, put: answer, patch: answer, delete: answer }
|
||||
});
|
||||
const gateway = broker.createService({
|
||||
mixins: [ApiGateway],
|
||||
settings: { port: 0, ip: '127.0.0.1', routes: [], ...(use ? { use } : {}) },
|
||||
methods: {
|
||||
// CoreService's: no credentials → anonymous.
|
||||
authenticate(ctx) {
|
||||
ctx.meta.webId = 'anon';
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
await broker.start();
|
||||
await broker.call('api.addRoute', { route: getCatchAllRoute('/', false) });
|
||||
return `http://127.0.0.1:${gateway.server.address().port}`;
|
||||
}
|
||||
|
||||
const status = async (base, method, p) => (await fetch(base + p, { method })).status;
|
||||
|
||||
test('BASELINE (no gate): the catch-all serves the key container to an anonymous caller', async () => {
|
||||
const base = await startGateway(undefined);
|
||||
expect(await status(base, 'GET', '/key')).toBe(200);
|
||||
});
|
||||
|
||||
test('with the gate: the key container, the root and any write are 404; a public key is served', async () => {
|
||||
const base = await startGateway([publicSurfaceGate]);
|
||||
expect(await status(base, 'GET', '/key')).toBe(404);
|
||||
expect(await status(base, 'GET', `/key/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'GET', '/')).toBe(404);
|
||||
expect(await status(base, 'GET', `/interop/data-grant/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'PUT', `/public-key/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'DELETE', `/interop/access-need/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'GET', `/public-key/${UUID}`)).toBe(200);
|
||||
expect(await status(base, 'GET', `/interop/access-need/${UUID}`)).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user