88f23b7023
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.
85 lines
5.0 KiB
JavaScript
85 lines
5.0 KiB
JavaScript
// The backend's whole HTTP surface, as an allow-list checked in front of every route.
|
|
//
|
|
// WHY. The backend runs SemApps' CoreService with `webacl: false`, so nothing checks rights on the
|
|
// resources it stores, and CoreService mounts routes Festipod never asked for — above all the LDP
|
|
// catch-all (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`,
|
|
// `authorization: false`), which served the backend's `settings` dataset to anyone: the app actor's
|
|
// PRIVATE key (`GET /key`, observed 2026-10-13), the user registrations and grants the app holds
|
|
// (`/interop/…`), the webhook listeners. A signed request is no better: any pod user can sign one.
|
|
// Rather than chase each route (deny-list), the gateway serves ONLY what a caller legitimately needs
|
|
// and refuses everything else with a 404 — for every caller, anonymous or signed, since none needs
|
|
// more over HTTP: the backend's own services reach its data through internal calls, never through
|
|
// these routes.
|
|
//
|
|
// WHAT IS PUBLIC, and who calls it (backend request log, 2026-10-09 → 2026-10-13, all e2e runs):
|
|
// GET /app the app description — the Festipod front end and the pod (install)
|
|
// GET /as/actor/app the app actor and its Solid-OIDC Client ID Document — the pod
|
|
// GET /catalog the public catalog — the Festipod front end
|
|
// GET /public-key/<id> the app actor's PUBLIC key — every pod verifying an app signature
|
|
// GET /interop/access-need-group/<id>, /interop/access-need/<id>
|
|
// what the app asks for — the pod, at install and consent
|
|
// GET /.well-known/context.jsonld the JSON-LD context the app's documents reference — the pod
|
|
// GET /.well-known/nodeinfo, /nodeinfo/2.1
|
|
// server discovery (links the app actor) — the pod
|
|
// POST /as/actor/app/inbox activities pods deliver to the app (HTTP-signature checked by the platform)
|
|
// POST /.webhooks/<id> the pods' notifications to the app's watcher (see emitted-activity.js)
|
|
// HEAD follows GET; a CORS preflight (OPTIONS) is judged on the method it announces.
|
|
//
|
|
// Everything else is refused, notably: `/key` (private keys), `/` and every other container listing,
|
|
// `/interop/*-registration|*-grant`, `/sparql` (also unmounted, `core.service.js`), `/proxy` (the
|
|
// signing proxy — the app uses it internally only), `/.well-known/webfinger` (unused), the app
|
|
// actor's other collections, and every write through the LDP routes.
|
|
|
|
// moleculer-web turns a thrown error's `code` into the HTTP status (it is not a direct dependency).
|
|
const { MoleculerClientError } = require('moleculer').Errors;
|
|
|
|
// One path segment as the backend mints them (UUIDs): no dot, so no `.`/`..` segment; no `%`, so no
|
|
// encoded separator or traversal; no empty segment.
|
|
const ID = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
|
|
|
const READ = ['GET', 'HEAD'];
|
|
|
|
const PUBLIC_ENDPOINTS = [
|
|
{ methods: READ, path: /^\/app$/ },
|
|
{ methods: READ, path: /^\/as\/actor\/app$/ },
|
|
{ methods: READ, path: /^\/catalog$/ },
|
|
{ methods: READ, path: new RegExp(`^/public-key/${ID}$`) },
|
|
{ methods: READ, path: new RegExp(`^/interop/access-need-group/${ID}$`) },
|
|
{ methods: READ, path: new RegExp(`^/interop/access-need/${ID}$`) },
|
|
{ methods: READ, path: /^\/\.well-known\/context\.jsonld$/ },
|
|
{ methods: READ, path: /^\/\.well-known\/nodeinfo$/ },
|
|
{ methods: READ, path: /^\/nodeinfo\/2\.1$/ },
|
|
{ methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ },
|
|
{ methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) }
|
|
];
|
|
|
|
/**
|
|
* isPublicRequest(method, path) → boolean. True only when `method` (upper case) on `path` (the
|
|
* request path without its query string, as the gateway routes it) is in `PUBLIC_ENDPOINTS`.
|
|
*/
|
|
function isPublicRequest(method, path) {
|
|
if (typeof method !== 'string' || typeof path !== 'string') return false;
|
|
const verb = method.toUpperCase();
|
|
return PUBLIC_ENDPOINTS.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(path));
|
|
}
|
|
|
|
/** The path the gateway routes on: moleculer-web's `parsedUrl` (query stripped, trailing `/` trimmed). */
|
|
function routedPathOf(req) {
|
|
if (typeof req.parsedUrl === 'string') return req.parsedUrl;
|
|
const raw = String(req.originalUrl || req.url || '').split('?')[0];
|
|
return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
|
}
|
|
|
|
/**
|
|
* publicSurfaceGate(req, res, next) — a moleculer-web global middleware (`api.use`, which the gateway
|
|
* puts in front of every route, including the ones services add at runtime). Passes a request in
|
|
* `PUBLIC_ENDPOINTS` through; answers any other with 404 before authentication or any action runs.
|
|
*/
|
|
function publicSurfaceGate(req, res, next) {
|
|
const method = req.method === 'OPTIONS' ? req.headers && req.headers['access-control-request-method'] : req.method;
|
|
if (isPublicRequest(method, routedPathOf(req))) return next();
|
|
return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND'));
|
|
}
|
|
|
|
module.exports = { isPublicRequest, publicSurfaceGate, PUBLIC_ENDPOINTS };
|