appsec: BOLA / authorization audit — backend/src/routes
Reviewer: appsec-reviewer (read-only) · Date: 2026-06-28
Fix owner (conceptual): backend-engineer — appsec reviews, does NOT implement.
Reference / gold standard (in-repo): backend/src/middleware/permissions.ts — requireAppPermission(action) (Permit.io checkAppPermission), requireOwnership, PermissionMiddleware.*.
backend/src/routes/organizations.ts already conforms (chains PermissionMiddleware.canRead/Update/DeleteOrganization + in-handler membership checks). backend/src/routes/apps.ts does not use any of these — it relies only on authenticateToken + a coarse, non-tenant-scoped requireRole(['admin']), and two endpoints have no auth at all.
Findings by severity
CRITICAL-1 — Unauthenticated app self-registration (apps.ts)
- Route:
POST /api/apps/register — router.post('/register', async ...) — no authenticateToken, only name/url presence checks.
- Risk: Any anonymous caller injects apps into the platform registry, including
module-federation apps with attacker-controlled remoteUrl/scope/module. The host shell loads these as federated remotes → arbitrary remote load / stored-XSS into every user's shell. Comment says "no auth required for demo" — shipped on master.
- Fix: Add
authenticateToken + requireAppPermission('create') (org-scoped). Remove or feature-flag this demo route; if kept, it MUST be authenticated and org-scoped, with the same schema validation as POST /api/apps. Validate + allow-list integrationType/remoteUrl.
CRITICAL-2 — Unauthenticated heartbeat + WebSocket injection (apps.ts)
- Route:
POST /api/apps/:id/heartbeat — router.post('/:id/heartbeat', async ...) — no authenticateToken.
- Risk: Anonymous caller with any app id emits
app-status-changed socket events to all connected clients (spoofed status / arbitrary metadata broadcast) and writes updated_at.
- Fix: Add
authenticateToken and authorize the specific app (requireAppPermission('update') / ownership), or restrict to a service-to-service shared-secret like internal.ts if it's a machine callback. Validate status against an allow-list; never broadcast raw metadata.
HIGH-3 — No object-level authz on admin app mutations (apps.ts)
- Routes:
PUT /api/apps/:id/activate and DELETE /api/apps/:id — gated only on global requireRole(['admin']).
- Risk: Any platform admin mutates/deletes ANY app regardless of org ownership — no tenant/owner scoping. The repo's own org-scoped
requireAppPermission('update'|'delete') is unused here.
- Fix: Replace the bare
requireRole(['admin']) with requireAppPermission('update') / requireAppPermission('delete') (Permit, org-scoped) and/or requireOwnership(req => getAppOwnerOrg(req.params.id)).
HIGH-4 — Missing object-level read scoping (apps.ts)
- Routes:
GET /api/apps, GET /api/apps/health — return ALL is_active apps to any authenticated user.
- Risk: No per-org /
visibility projection (the App type carries visibility, marketplaceMetadata, but neither is filtered). Collection-level BOLA / cross-tenant read exposure.
- Fix: Filter by the caller's org membership + app
visibility; project only entitled fields (mirror the membership join used in organizations.ts GET).
MEDIUM-5 — Mass-assignment / BOPLA + ad-hoc validation (apps.ts)
PUT /:id/activate writes is_active straight from req.body.isActive (no boolean coercion / schema). POST / and POST /register build inserts from raw body. Validation is ad-hoc regex, not a schema, and does not reject unknown fields.
- Fix: Introduce a Zod (or equivalent) schema per body, set only an explicit allow-list of columns, reject unknown fields, coerce
isActive to boolean.
Not findings (verified compliant)
backend/src/routes/internal.ts POST /internal/provision — constant-time shared-secret compare, fail-closed. OK.
backend/src/routes/organizations.ts GET/PUT/DELETE /:id — PermissionMiddleware.* + in-handler membership checks. OK (this is the pattern apps.ts should adopt).
Suggested verification (hand to backend-engineer)
For each fixed route add a test: non-owner / wrong-org gets 403, owner/entitled gets 200; unauthenticated gets 401 on /register and /:id/heartbeat.
Note: governance/architecture-guidelines.md was not present on master at audit time; the in-repo reference (permissions.ts) is used as the conformance anchor.
appsec: BOLA / authorization audit — backend/src/routes
Reviewer: appsec-reviewer (read-only) · Date: 2026-06-28
Fix owner (conceptual):
backend-engineer— appsec reviews, does NOT implement.Reference / gold standard (in-repo):
backend/src/middleware/permissions.ts—requireAppPermission(action)(Permit.iocheckAppPermission),requireOwnership,PermissionMiddleware.*.backend/src/routes/organizations.tsalready conforms (chainsPermissionMiddleware.canRead/Update/DeleteOrganization+ in-handler membership checks).backend/src/routes/apps.tsdoes not use any of these — it relies only onauthenticateToken+ a coarse, non-tenant-scopedrequireRole(['admin']), and two endpoints have no auth at all.Findings by severity
CRITICAL-1 — Unauthenticated app self-registration (
apps.ts)POST /api/apps/register—router.post('/register', async ...)— noauthenticateToken, onlyname/urlpresence checks.module-federationapps with attacker-controlledremoteUrl/scope/module. The host shell loads these as federated remotes → arbitrary remote load / stored-XSS into every user's shell. Comment says "no auth required for demo" — shipped on master.authenticateToken+requireAppPermission('create')(org-scoped). Remove or feature-flag this demo route; if kept, it MUST be authenticated and org-scoped, with the same schema validation asPOST /api/apps. Validate + allow-listintegrationType/remoteUrl.CRITICAL-2 — Unauthenticated heartbeat + WebSocket injection (
apps.ts)POST /api/apps/:id/heartbeat—router.post('/:id/heartbeat', async ...)— noauthenticateToken.app-status-changedsocket events to all connected clients (spoofed status / arbitrarymetadatabroadcast) and writesupdated_at.authenticateTokenand authorize the specific app (requireAppPermission('update')/ ownership), or restrict to a service-to-service shared-secret likeinternal.tsif it's a machine callback. Validatestatusagainst an allow-list; never broadcast rawmetadata.HIGH-3 — No object-level authz on admin app mutations (
apps.ts)PUT /api/apps/:id/activateandDELETE /api/apps/:id— gated only on globalrequireRole(['admin']).requireAppPermission('update'|'delete')is unused here.requireRole(['admin'])withrequireAppPermission('update')/requireAppPermission('delete')(Permit, org-scoped) and/orrequireOwnership(req => getAppOwnerOrg(req.params.id)).HIGH-4 — Missing object-level read scoping (
apps.ts)GET /api/apps,GET /api/apps/health— return ALLis_activeapps to any authenticated user.visibilityprojection (theApptype carriesvisibility,marketplaceMetadata, but neither is filtered). Collection-level BOLA / cross-tenant read exposure.visibility; project only entitled fields (mirror the membership join used inorganizations.tsGET).MEDIUM-5 — Mass-assignment / BOPLA + ad-hoc validation (
apps.ts)PUT /:id/activatewritesis_activestraight fromreq.body.isActive(no boolean coercion / schema).POST /andPOST /registerbuild inserts from raw body. Validation is ad-hoc regex, not a schema, and does not reject unknown fields.isActiveto boolean.Not findings (verified compliant)
backend/src/routes/internal.tsPOST /internal/provision— constant-time shared-secret compare, fail-closed. OK.backend/src/routes/organizations.tsGET/PUT/DELETE/:id—PermissionMiddleware.*+ in-handler membership checks. OK (this is the patternapps.tsshould adopt).Suggested verification (hand to backend-engineer)
For each fixed route add a test: non-owner / wrong-org gets 403, owner/entitled gets 200; unauthenticated gets 401 on
/registerand/:id/heartbeat.