sec: lock down 5 cross-tenant FK gaps from fifth-pass review
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
berthId from the body and persisted them with no port check; getReminder
then hydrated the row via Drizzle relations (no port filter on the
join), so a port-A user with reminders:create could exfiltrate any
port-B client/interest/berth row by guessing its UUID. New
assertReminderFksInPort gates create + update.
2. HIGH — listRecommendations(interestId, _portId) discarded portId
entirely; the route GET /api/v1/interests/[id]/recommendations
forwarded the URL id straight through. A port-A user with
interests:view could read any other tenant's recommended berths
(mooring numbers, dimensions, status). Service now verifies the
interest belongs to portId and joins berths filtered by port.
3. HIGH — Berth waiting list. The PATCH route did not pre-check that
the berth belonged to ctx.portId — a port-A user with
manage_waiting_list could reorder a port-B berth's queue. Separately,
updateWaitingList accepted arbitrary entries[].clientId and inserted
them without verifying tenancy, polluting the table with foreign-port
FKs. Both gaps closed.
4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
accepted any tagId and inserted into the join table. The tags table
is per-port but the join only carries a single-column FK. The
downstream getById join `tags ON join.tag_id = tags.id` has no port
filter, so a foreign tag's name + color render in the requesting port.
Helper now batch-validates tagIds belong to portId before insert.
5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
gate (any role, including viewer, could write) and didn't validate
that the URL entityId pointed at a port-scoped entity of the field
definition's entityType. Route now uses
withPermission('clients','view'/'edit',…); service validates the
entityId per resolved entityType (client/interest/berth/yacht/company)
against portId.
Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,13 @@ vi.mock('@/lib/db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
customFieldDefinitions: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
// Entity-port-scope checks added by the security fix; default to a
|
||||
// truthy row so existing assertions still focus on validation logic.
|
||||
clients: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
||||
interests: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
||||
berths: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
||||
yachts: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
||||
companies: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
||||
},
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -20,6 +27,12 @@ vi.mock('@/lib/db', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db/schema/clients', () => ({ clients: {} }));
|
||||
vi.mock('@/lib/db/schema/interests', () => ({ interests: {} }));
|
||||
vi.mock('@/lib/db/schema/berths', () => ({ berths: {} }));
|
||||
vi.mock('@/lib/db/schema/yachts', () => ({ yachts: {} }));
|
||||
vi.mock('@/lib/db/schema/companies', () => ({ companies: {} }));
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -84,7 +97,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
/** Convenience: call setValues with a single field/value pair. */
|
||||
async function validate(fieldType: string, value: unknown, extras?: { isRequired?: boolean; selectOptions?: string[] }) {
|
||||
async function validate(
|
||||
fieldType: string,
|
||||
value: unknown,
|
||||
extras?: { isRequired?: boolean; selectOptions?: string[] },
|
||||
) {
|
||||
(db.query.customFieldDefinitions.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
makeDefinition(fieldType, extras),
|
||||
]);
|
||||
@@ -182,7 +199,9 @@ describe('custom field validation — select', () => {
|
||||
});
|
||||
|
||||
it('rejects an option not in the list', async () => {
|
||||
await expect(validate('select', 'XL', { selectOptions: options })).rejects.toBeInstanceOf(ValidationError);
|
||||
await expect(validate('select', 'XL', { selectOptions: options })).rejects.toBeInstanceOf(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('error message lists the valid options', async () => {
|
||||
@@ -203,11 +222,15 @@ describe('custom field validation — select', () => {
|
||||
|
||||
describe('custom field validation — required vs optional null', () => {
|
||||
it('required field: null value → throws ValidationError', async () => {
|
||||
await expect(validate('text', null, { isRequired: true })).rejects.toBeInstanceOf(ValidationError);
|
||||
await expect(validate('text', null, { isRequired: true })).rejects.toBeInstanceOf(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('required field: undefined value → throws ValidationError', async () => {
|
||||
await expect(validate('text', undefined, { isRequired: true })).rejects.toBeInstanceOf(ValidationError);
|
||||
await expect(validate('text', undefined, { isRequired: true })).rejects.toBeInstanceOf(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('non-required field: null value → succeeds (no error)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user