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 { z } from 'zod';
|
||||||
|
|
||||||
import { baseListQuerySchema } from '@/lib/api/list-query';
|
import { baseListQuerySchema } from '@/lib/api/list-query';
|
||||||
|
import { humanTextSchema } from '@/lib/validators/text';
|
||||||
import {
|
import {
|
||||||
optionalCountryIsoSchema,
|
optionalCountryIsoSchema,
|
||||||
optionalIanaTimezoneSchema,
|
optionalIanaTimezoneSchema,
|
||||||
@@ -9,22 +10,35 @@ import {
|
|||||||
|
|
||||||
// ─── Contact sub-schema ──────────────────────────────────────────────────────
|
// ─── Contact sub-schema ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const contactSchema = z.object({
|
export const contactSchema = z
|
||||||
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
|
.object({
|
||||||
value: z.string().min(1),
|
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
|
||||||
/** E.164-normalized number; required when channel is phone/whatsapp. */
|
value: z.string().min(1),
|
||||||
valueE164: optionalPhoneE164Schema.optional(),
|
/** E.164-normalized number; required when channel is phone/whatsapp. */
|
||||||
/** ISO-3166-1 alpha-2 country the number was parsed against. */
|
valueE164: optionalPhoneE164Schema.optional(),
|
||||||
valueCountry: optionalCountryIsoSchema.optional(),
|
/** ISO-3166-1 alpha-2 country the number was parsed against. */
|
||||||
label: z.string().optional(),
|
valueCountry: optionalCountryIsoSchema.optional(),
|
||||||
isPrimary: z.boolean().optional().default(false),
|
label: z.string().optional(),
|
||||||
notes: 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 ──────────────────────────────────────────────────────────────────
|
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const createClientSchema = z.object({
|
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'),
|
contacts: z.array(contactSchema).min(1, 'At least one contact is required'),
|
||||||
/** ISO-3166-1 alpha-2 nationality code. */
|
/** ISO-3166-1 alpha-2 nationality code. */
|
||||||
nationalityIso: optionalCountryIsoSchema.optional(),
|
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