Patterns — Validating Input
Contents
- Pydantic request schema (strict, canonicalizing)
- Zod equivalent
- Structured 422 handler
- Path traversal guard
- Upload validation
- Gotchas
Pydantic request schema (strict, canonicalizing)
python
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
import unicodedata
class CreateUser(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=200)
email: EmailStr
age: int = Field(ge=13, le=130)
role: Literal["member", "editor"] # closed set — allowlist
tags: list[str] = Field(default_factory=list, max_length=50)
@field_validator("name")
@classmethod
def canonicalize(cls, v: str) -> str:
return unicodedata.normalize("NFC", v) # canonicalize BEFORE checks
@field_validator("email")
@classmethod
def lower_email(cls, v: str) -> str:
return v.lower()Zod equivalent
ts
const CreateUser = z.object({
name: z.string().trim().min(1).max(200),
email: z.string().email().toLowerCase(),
age: z.number().int().min(13).max(130),
role: z.enum(["member", "editor"]),
tags: z.array(z.string().max(50)).max(50).default([]),
}).strict(); // reject unknown fieldsStructured 422 handler
python
# FastAPI: collect ALL field errors in one response.
@app.exception_handler(RequestValidationError)
async def on_validation_error(request, exc):
return JSONResponse(status_code=422, content={
"type": "https://example.com/errors/validation",
"title": "Request validation failed",
"errors": [
{"field": ".".join(map(str, e["loc"])), "reason": e["msg"]}
for e in exc.errors()
],
})json
{"title": "Request validation failed",
"errors": [{"field": "body.age", "reason": "Input should be less than or equal to 130"}]}Path traversal guard
python
from pathlib import Path
BASE = Path("/srv/app/uploads").resolve()
def safe_path(user_supplied: str) -> Path:
candidate = (BASE / user_supplied).resolve()
if not candidate.is_relative_to(BASE): # blocks ../.. and absolute paths
raise ValueError("path escapes base directory")
return candidateUpload validation
python
MAX_UPLOAD = 10 * 1024 * 1024 # 10 MB — set per product need, never unlimited
MAGIC = {b"\x89PNG": "image/png", b"\xff\xd8\xff": "image/jpeg", b"%PDF": "application/pdf"}
def check_upload(stream, declared_type: str):
head = stream.read(8); stream.seek(0)
sniffed = next((t for magic, t in MAGIC.items() if head.startswith(magic)), None)
if sniffed is None or sniffed != declared_type:
raise ValueError("content does not match declared type") # never trust filename/Content-TypeEnforce MAX_UPLOAD at the web server (nginx client_max_body_size) as well —
before the app buffers anything.
Gotchas
extra="ignore"(the common default) silently drops attacker fields today and mass-assigns them after the next model refactor; alwaysforbid.- Unanchored regex —
re.search("[a-z]+")passes"$(rm -rf /)abc"; anchor^...$and preferfullmatch. - Unicode homoglyphs after validation — normalize (NFC/NFKC) first or
caféandcafé(combining accent) count as different users. intcoercion of booleans — in PythonTrueis anint; pydantic v2strict=Trueor explicitStrictIntwhere it matters.- Trusting Content-Length — read with a hard cap; a lying client otherwise OOMs the worker.
- Validating after parsing huge JSON — depth/size bombs hit the parser first; cap body size and nesting at the server layer.
- First-error-only responses — clients fix one field per round-trip; return all errors at once (see handler above).