Skip to content

appsec: BOLA/authz audit (apps.ts) — 2026-06-28 #100

Description

@fuzeone-bot

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.tsrequireAppPermission(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/registerrouter.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/heartbeatrouter.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 /:idPermissionMiddleware.* + 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity finding / hardening

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions