# 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 fields ``` ## Structured 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 candidate ``` ## Upload 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-Type ``` Enforce `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; always `forbid`. - **Unanchored regex** — `re.search("[a-z]+")` passes `"$(rm -rf /)abc"`; anchor `^...$` and prefer `fullmatch`. - **Unicode homoglyphs after validation** — normalize (NFC/NFKC) first or `café` and `café` (combining accent) count as different users. - **`int` coercion of booleans** — in Python `True` is an `int`; pydantic v2 `strict=True` or explicit `StrictInt` where 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).