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>
142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
/**
|
||
* i18n PR9–10 — public residential inquiry endpoint.
|
||
*
|
||
* Validates the server-side phone normalization that the public inquiry
|
||
* route runs when the website posts a raw international format (older
|
||
* Nuxt builds), and that pre-normalized payloads pass through unchanged.
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||
import { eq } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { residentialClients } from '@/lib/db/schema/residential';
|
||
import { makePort } from '../helpers/factories';
|
||
import { makeMockRequest } from '../helpers/route-tester';
|
||
|
||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||
vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
|
||
|
||
// Randomize per-run prefix so leftover redis sliding-window entries from a
|
||
// previous run don't 429 the new run (publicForm limiter pexpires after 1h).
|
||
const IP_PREFIX = `10.${Math.floor(Math.random() * 200) + 10}`;
|
||
let ipCounter = 1;
|
||
function uniqueIp(): string {
|
||
ipCounter += 1;
|
||
return `${IP_PREFIX}.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`;
|
||
}
|
||
|
||
describe('POST /api/public/residential-inquiries', () => {
|
||
let POST: typeof import('@/app/api/public/residential-inquiries/route').POST;
|
||
|
||
beforeAll(async () => {
|
||
const mod = await import('@/app/api/public/residential-inquiries/route');
|
||
POST = mod.POST;
|
||
});
|
||
|
||
it('parses a raw international phone string into E.164 + country', async () => {
|
||
const port = await makePort();
|
||
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||
|
||
const req = makeMockRequest(
|
||
'POST',
|
||
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
|
||
{
|
||
headers: { 'x-forwarded-for': uniqueIp() },
|
||
body: {
|
||
firstName: 'Anna',
|
||
lastName: 'Nowak',
|
||
email,
|
||
// Raw international format — server should normalize.
|
||
phone: '+44 20 7946 0958',
|
||
placeOfResidence: 'Warsaw',
|
||
},
|
||
},
|
||
);
|
||
|
||
const res = await POST(req);
|
||
expect(res.status).toBe(201);
|
||
|
||
const [row] = await db
|
||
.select()
|
||
.from(residentialClients)
|
||
.where(eq(residentialClients.email, email));
|
||
|
||
expect(row).toBeDefined();
|
||
expect(row?.phoneE164).toBe('+442079460958');
|
||
expect(row?.phoneCountry).toBe('GB');
|
||
// Free-text legacy column preserved verbatim for backfill.
|
||
expect(row?.phone).toBe('+44 20 7946 0958');
|
||
});
|
||
|
||
it('passes pre-normalized E.164 + country through unchanged', async () => {
|
||
const port = await makePort();
|
||
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||
|
||
const req = makeMockRequest(
|
||
'POST',
|
||
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
|
||
{
|
||
headers: { 'x-forwarded-for': uniqueIp() },
|
||
body: {
|
||
firstName: 'Jan',
|
||
lastName: 'Kowalski',
|
||
email,
|
||
phone: '+48 22 555 0100',
|
||
phoneE164: '+48225550100',
|
||
phoneCountry: 'PL',
|
||
nationalityIso: 'PL',
|
||
timezone: 'Europe/Warsaw',
|
||
placeOfResidence: 'Warsaw',
|
||
placeOfResidenceCountryIso: 'PL',
|
||
},
|
||
},
|
||
);
|
||
|
||
const res = await POST(req);
|
||
expect(res.status).toBe(201);
|
||
|
||
const [row] = await db
|
||
.select()
|
||
.from(residentialClients)
|
||
.where(eq(residentialClients.email, email));
|
||
|
||
expect(row?.phoneE164).toBe('+48225550100');
|
||
expect(row?.phoneCountry).toBe('PL');
|
||
expect(row?.nationalityIso).toBe('PL');
|
||
expect(row?.timezone).toBe('Europe/Warsaw');
|
||
expect(row?.placeOfResidenceCountryIso).toBe('PL');
|
||
});
|
||
|
||
it('persists a national-format phone when the website only sends a country hint', async () => {
|
||
const port = await makePort();
|
||
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||
|
||
const req = makeMockRequest(
|
||
'POST',
|
||
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
|
||
{
|
||
headers: { 'x-forwarded-for': uniqueIp() },
|
||
body: {
|
||
firstName: 'Marta',
|
||
lastName: 'Lewandowska',
|
||
email,
|
||
phone: '22 555 0200', // National-format
|
||
phoneCountry: 'PL', // Hint only — no E.164 yet.
|
||
},
|
||
},
|
||
);
|
||
|
||
const res = await POST(req);
|
||
expect(res.status).toBe(201);
|
||
|
||
const [row] = await db
|
||
.select()
|
||
.from(residentialClients)
|
||
.where(eq(residentialClients.email, email));
|
||
|
||
expect(row?.phoneE164).toBe('+48225550200');
|
||
expect(row?.phoneCountry).toBe('PL');
|
||
});
|
||
});
|