Files
pn-new-crm/scripts/dev-trigger-crm-invite.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

45 lines
1.4 KiB
TypeScript

/**
* Dev-only helper: issue a CRM admin invite and send the activation email.
* The email gets routed via EMAIL_REDIRECT_TO if that's set, so it always
* lands in the dev inbox.
*
* Run: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]
*/
import 'dotenv/config';
import { createCrmInvite } from '@/lib/services/crm-invite.service';
async function main() {
const args = process.argv.slice(2);
const email = args[0];
if (!email) {
console.error('Usage: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]');
process.exit(1);
}
const isSuperAdmin = args.includes('--super');
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
// Dev script runs out-of-band (no HTTP request, no session). The service's
// super-admin gate requires `invitedBy.isSuperAdmin === true` for super
// invites; the script bypasses that with a synthetic caller identity.
const { inviteId, link } = await createCrmInvite({
email,
name,
isSuperAdmin,
invitedBy: { userId: 'cli-script', isSuperAdmin: true },
});
console.log(`✓ Invite created (id=${inviteId})`);
console.log(` email: ${email}`);
console.log(` super_admin: ${isSuperAdmin}`);
console.log(` activation link: ${link}`);
console.log('');
console.log('Email sent (subject permitting via EMAIL_REDIRECT_TO).');
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});