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>
45 lines
1.4 KiB
TypeScript
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);
|
|
});
|