Files
pn-new-crm/tests/integration/crm-invite-super-admin-gate.test.ts

33 lines
1.1 KiB
TypeScript
Raw Normal View History

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
/**
* 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);
});
});