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:
2026-05-14 22:44:15 +02:00
parent 27f8db4c67
commit 2d0a49e0d1
2 changed files with 26 additions and 12 deletions

View File

@@ -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

Binary file not shown.