fix(P1): input validation hardening for client API — F6
Pre-audit /api/v1/clients accepted:
- contacts[].value='not-an-email' with channel='email' → silent bounce
- fullName=' ' (whitespace-only) → blank-chip renders everywhere
- fullName='Hidden<ZWSP>Char<ZWSP>Name' (zero-width chars) → search blind spot
This commit:
1. New `humanTextSchema()` helper in src/lib/validators/text.ts that
strips invisible/bidi/control chars, trims, then length-checks.
2. `fullName` switched to `humanTextSchema({ min: 1, max: 200 })`.
3. `contactSchema` gains a `superRefine` requiring valid email format
when `channel === 'email'`.
Verified live:
- invalid email → 400 "Must be a valid email address." (field-scoped)
- whitespace name → 400 "Too small: expected string to have >=1 characters"
- zero-width chars → stored as cleaned "HiddenCharName"
- valid baseline → 201
Followup tasks (deferred): apply `humanTextSchema` to yachts/companies/
interests/notes/reminders names; audit render paths for XSS-via-stored-
HTML (default React escaping is safe; pdfme/email-merge surfaces need a
spot-check).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { baseListQuerySchema } from '@/lib/api/list-query';
|
||||
import { humanTextSchema } from '@/lib/validators/text';
|
||||
import {
|
||||
optionalCountryIsoSchema,
|
||||
optionalIanaTimezoneSchema,
|
||||
@@ -9,22 +10,35 @@ import {
|
||||
|
||||
// ─── Contact sub-schema ──────────────────────────────────────────────────────
|
||||
|
||||
export const contactSchema = z.object({
|
||||
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
|
||||
value: z.string().min(1),
|
||||
/** E.164-normalized number; required when channel is phone/whatsapp. */
|
||||
valueE164: optionalPhoneE164Schema.optional(),
|
||||
/** ISO-3166-1 alpha-2 country the number was parsed against. */
|
||||
valueCountry: optionalCountryIsoSchema.optional(),
|
||||
label: z.string().optional(),
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export const contactSchema = z
|
||||
.object({
|
||||
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
|
||||
value: z.string().min(1),
|
||||
/** E.164-normalized number; required when channel is phone/whatsapp. */
|
||||
valueE164: optionalPhoneE164Schema.optional(),
|
||||
/** ISO-3166-1 alpha-2 country the number was parsed against. */
|
||||
valueCountry: optionalCountryIsoSchema.optional(),
|
||||
label: z.string().optional(),
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Post-audit F6: email channel must carry a syntactically valid
|
||||
// address. Otherwise sales send-outs bounce silently and the bounce
|
||||
// monitor can't classify.
|
||||
if (data.channel === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.value)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: 'Must be a valid email address.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const createClientSchema = z.object({
|
||||
fullName: z.string().min(1).max(200),
|
||||
fullName: humanTextSchema({ min: 1, max: 200 }),
|
||||
contacts: z.array(contactSchema).min(1, 'At least one contact is required'),
|
||||
/** ISO-3166-1 alpha-2 nationality code. */
|
||||
nationalityIso: optionalCountryIsoSchema.optional(),
|
||||
|
||||
BIN
src/lib/validators/text.ts
Normal file
BIN
src/lib/validators/text.ts
Normal file
Binary file not shown.
Reference in New Issue
Block a user