From 2d0a49e0d1c3f7e26bc83b31ba84ba5ba9418fd5 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 22:44:15 +0200 Subject: [PATCH] =?UTF-8?q?fix(P1):=20input=20validation=20hardening=20for?= =?UTF-8?q?=20client=20API=20=E2=80=94=20F6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-audit /api/v1/clients accepted: - contacts[].value='not-an-email' with channel='email' → silent bounce - fullName=' ' (whitespace-only) → blank-chip renders everywhere - fullName='HiddenCharName' (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) --- src/lib/validators/clients.ts | 38 +++++++++++++++++++++++----------- src/lib/validators/text.ts | Bin 0 -> 1887 bytes 2 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 src/lib/validators/text.ts diff --git a/src/lib/validators/clients.ts b/src/lib/validators/clients.ts index cbefa7bd..1b4ab11b 100644 --- a/src/lib/validators/clients.ts +++ b/src/lib/validators/clients.ts @@ -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(), diff --git a/src/lib/validators/text.ts b/src/lib/validators/text.ts new file mode 100644 index 0000000000000000000000000000000000000000..d025a59c3acce9435dd3a008b02957f5b1106426 GIT binary patch literal 1887 zcmaJ?%Wfk@6rCmVAbx?nb9drb0vc&-G9$-hE0HqE$VotgpmDkTIxeNVs#R5;hogwF zX2AmXux7yzut4Hd`~&;~r@B41lZ;q(JzV#3&OOI6&9rfJOEbEoiP0%lXF9HSDwXEO zMuj%$*cq8wnz7OKD;c{fZPHlAk|M2~(FsLUVMOHESaMV0NEjxmE~S;@gvn`~vJb>J zOyk_q*s!=D%fdv{ruE5%iYZ}yofFZ84>Z-OJ~twp)@`QsgjhN0ZhRCaQnn51=}Z5+ z)6MNxYj=MCOR*T#ii7R$;$XX99=vGz@1Q>zG!UX$LFLxb{^8gA$NRen{rA1S?(q2i zuunVG{BC~#Q+R!^#Ap$XI*^oAz_ zaL@!`gP1im7f^;p$pkXikwd1j)(H#MsoX6!v71YPPr!NB@Vu$>ARN)9NMtM=xbl4E zmQ5X9;F6}kk2>Z)7KJ9z(TYO~ikYqvMm%9#qnK@kStvS2RoyI0B>QZ+l8Bs=6-qie z6aE)%1oE+nfcZkJEA=%WE6G>0609E!yxd;C{D`Il^rBUA<7JEm3emZt}{7@i$q^E=)}m>;+1r4GjP1P`LaQMh2|o}2fUd28W(BSBzDA31vr>(1tJeH0I zFWQtDu21B3jYg9^Ned5QaKUlxmlX1-It8c$Ke)p<}ROQOQ!tbvbg^eK* z5>dpNgVM)^qpi(Q0|fPUWc_u>Vct_db`8Q2lIL@A1)?s$hdhZIeJb1V;Sv7m&3k~Y1rjP(F{wX zr6kLpGeTJpVG!7mc3zQf7|s&RUhwoUphnedxV&E$Sswer3&OAro5_rWmHZ3pKwn87 Kun0r%82