Files
pn-new-crm/tests/integration/crm-invite-super-admin-gate.test.ts
Matt Ciaccio 4c5334d471 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>
2026-04-29 02:27:01 +02:00

33 lines
1.1 KiB
TypeScript

/**
* Security regression: only an existing super-admin caller can mint a
* super-admin CRM invitation. A port `director` (or any caller without
* `invitedBy.isSuperAdmin === true`) must be rejected at the service layer
* even if the route handler somehow lets the body flag through.
*/
import { describe, it, expect } from 'vitest';
import { createCrmInvite } from '@/lib/services/crm-invite.service';
import { ValidationError } from '@/lib/errors';
describe('createCrmInvite — super-admin gate', () => {
it('rejects super-admin invites when caller is not a super-admin', async () => {
await expect(
createCrmInvite({
email: `attacker-${Date.now()}@example.test`,
isSuperAdmin: true,
invitedBy: { userId: 'director-id', isSuperAdmin: false },
}),
).rejects.toThrow(ValidationError);
});
it('rejects super-admin invites when invitedBy is omitted entirely', async () => {
await expect(
createCrmInvite({
email: `attacker-${Date.now()}-noctx@example.test`,
isSuperAdmin: true,
}),
).rejects.toThrow(ValidationError);
});
});