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>
73 lines
2.5 KiB
TypeScript
73 lines
2.5 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
|
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
|
import { parseBody } from '@/lib/api/route-helpers';
|
|
import { errorResponse } from '@/lib/errors';
|
|
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
|
|
|
|
const saveSchema = z.object({
|
|
/** When 'global', requires super_admin and stores at port_id=null. */
|
|
scope: z.enum(['port', 'global']),
|
|
provider: z.enum(['openai', 'claude']),
|
|
model: z.string().min(1),
|
|
apiKey: z.string().optional(),
|
|
clearApiKey: z.boolean().optional(),
|
|
useGlobal: z.boolean().optional(),
|
|
aiEnabled: z.boolean().optional(),
|
|
});
|
|
|
|
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
|
// may read or write the OCR config: the apiKey is stored encrypted but is
|
|
// passed straight into the receipt-scan handler, so a swapped key would
|
|
// exfiltrate every subsequent receipt image to whatever endpoint that key
|
|
// authenticates with.
|
|
export const GET = withAuth(
|
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
|
try {
|
|
const url = new URL(req.url);
|
|
const scope = url.searchParams.get('scope') ?? 'port';
|
|
if (scope === 'global' && !ctx.isSuperAdmin) {
|
|
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
|
}
|
|
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
|
|
return NextResponse.json({ data: config, models: OCR_MODELS });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}),
|
|
);
|
|
|
|
export const PUT = withAuth(
|
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
|
try {
|
|
const body = await parseBody(req, saveSchema);
|
|
if (body.scope === 'global' && !ctx.isSuperAdmin) {
|
|
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
|
}
|
|
const validModels = OCR_MODELS[body.provider];
|
|
if (!validModels.includes(body.model)) {
|
|
return NextResponse.json(
|
|
{ error: `Invalid model for provider ${body.provider}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
await saveOcrConfig(
|
|
body.scope === 'global' ? null : ctx.portId,
|
|
{
|
|
provider: body.provider,
|
|
model: body.model,
|
|
apiKey: body.apiKey,
|
|
clearApiKey: body.clearApiKey,
|
|
useGlobal: body.useGlobal,
|
|
aiEnabled: body.aiEnabled,
|
|
},
|
|
ctx.userId,
|
|
);
|
|
return NextResponse.json({ ok: true });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}),
|
|
);
|