sec: gate super-admin invite minting, OCR settings, and alert mutations

Three findings from the branch security review:

1. HIGH — Privilege escalation via super-admin invite. POST
   /api/v1/admin/invitations was gated only by manage_users (held by the
   port-scoped director role). The body schema accepted isSuperAdmin
   from the request, createCrmInvite persisted it verbatim, and
   consumeCrmInvite copied it into userProfiles.isSuperAdmin — granting
   the new account cross-tenant access. Now the route rejects
   isSuperAdmin=true unless ctx.isSuperAdmin, and createCrmInvite
   requires invitedBy.isSuperAdmin as defense-in-depth.

2. HIGH — Receipt-image exfiltration via OCR settings. The route
   /api/v1/admin/ocr-settings (and the sibling /test) were wrapped only
   in withAuth — any port role including viewer could PUT a swapped
   provider apiKey + flip aiEnabled, redirecting every subsequent
   receipt scan to attacker infrastructure. Both are now wrapped in
   withPermission('admin','manage_settings',…) matching the sibling
   admin routes (ai-budget, settings).

3. MEDIUM — Cross-tenant alert IDOR. dismissAlert / acknowledgeAlert
   issued UPDATE … WHERE id=? with no portId predicate. Any
   authenticated user with a foreign alert UUID could mutate it. Both
   service functions now require portId and add it to the WHERE; the
   route handlers pass ctx.portId.

The dev-trigger-crm-invite script passes a synthetic super-admin caller
identity since it runs out-of-band.

The two public-form tests randomize their IP prefix per run so a fresh
test process doesn't collide with leftover redis sliding-window entries
from a prior run (publicForm limiter pexpires after 1h).

Two new regression test files cover the fixes (6 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-29 02:27:01 +02:00
parent 61e40b5e76
commit 4c5334d471
12 changed files with 238 additions and 67 deletions

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
export const GET = withAuth(
@@ -24,10 +24,17 @@ const createInviteSchema = z.object({
});
export const POST = withAuth(
withPermission('admin', 'manage_users', async (req, _ctx) => {
withPermission('admin', 'manage_users', async (req, ctx) => {
try {
const body = await parseBody(req, createInviteSchema);
const result = await createCrmInvite(body);
// Only existing super-admins can mint super-admin invitations. The
// manage_users permission is granted to port-scoped director roles,
// which must not be able to elevate themselves cross-tenant by
// inviting a fresh super_admin.
if (body.isSuperAdmin && !ctx.isSuperAdmin) {
throw new ForbiddenError('Only super admins can mint super-admin invitations');
}
const result = await createCrmInvite({ ...body, invitedBy: ctx });
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {
return errorResponse(error);