# Loom Data Validation

> Data validation patterns covering schema validation, input sanitization, output encoding, and type coercion.

- Skill: `cosmix/loom-data-validation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add cosmix/loom-data-validation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/cosmix/loom-data-validation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: cosmix (https://skillmd.com/u/cosmix)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/cosmix/loom-data-validation

---


# Data Validation

## Overview

Validate untrusted data at trust boundaries before it flows into your system. This skill covers schema libraries (Zod/Pydantic/Joi/JSON Schema), coercion pitfalls, context-dependent output encoding, injection/XSS/DoS defenses, and pipeline/ML feature validation.

## Core principles (read first)

- **Parse, don't validate.** A validator that returns `bool` throws away work — the caller re-parses or trusts blindly. Return a *typed value* (`Result<User>`, `User | errors`) so downstream code cannot receive unvalidated data. Schema libraries (Zod `.parse`, Pydantic `.model_validate`) do this by construction.
- **Validate at the boundary, once, then trust the typed value inward.** Boundaries: HTTP handlers, queue consumers, file/CLI parsers, pipeline ingestion, cross-service calls.
- **Server-side is authoritative; client-side validation is UX only.** Never rely on it for security — attackers bypass the client entirely.
- **Allowlist > denylist.** Enumerate what's permitted (`enum`, char classes, known hosts). Denylists (blocking `<script>`, `../`, `'`) are always incomplete — encodings, Unicode, and case defeat them.
- **Canonicalize before validating.** Normalize Unicode (NFC), lowercase host, resolve `.`/`..` in paths, decode percent-encoding — *then* check. Validating raw input lets `%2e%2e%2f` or `．` (fullwidth) slip past.
- **Encoding ≠ validation.** Validation decides *accept/reject*; encoding makes a value *safe for a specific sink* (HTML vs attribute vs JS vs URL vs shell vs SQL). A value can be valid and still need encoding at every sink.
- **Limits are validation.** Cap length, array size, object depth, and total payload bytes to stop DoS (JSON bombs, deeply nested payloads, ReDoS amplification).

## Zod (TypeScript)

`safeParse` returns a discriminated result (no throw); `parse` throws `ZodError`. Prefer `safeParse` at boundaries.

```typescript
import { z } from "zod";

const CreateUser = z
  .object({
    email: z.string().trim().toLowerCase().email().max(255),
    password: z.string().min(12).max(128)
      .regex(/[a-z]/).regex(/[A-Z]/).regex(/\d/).regex(/[^A-Za-z0-9]/),
    role: z.enum(["user", "admin", "moderator"]).default("user"),
    tags: z.array(z.string().max(50)).max(10).default([]),
    age: z.number().int().min(13).max(150).optional(),
  })
  .strict();            // reject unknown keys → blocks mass-assignment/overposting

type CreateUserIn = z.input<typeof CreateUser>;   // pre-transform
type CreateUserOut = z.output<typeof CreateUser>; // post-transform (use this downstream)

const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) {
  const errors = parsed.error.issues.map((e) => ({ field: e.path.join("."), message: e.message }));
  return res.status(422).json({ errors });
}
const user = parsed.data; // fully typed + validated
```

- **`.strict()` vs `.strip()` (default) vs `.passthrough()`:** default silently *drops* unknown keys — safe but hides typos. `.strict()` rejects them (best for request bodies). Never `.passthrough()` untrusted input into a DB writer (overposting).
- **`z.discriminatedUnion`** for tagged variants — faster and clearer errors than `z.union`. **`z.lazy`** for recursive schemas.
- **Async rules** (uniqueness, DB lookups) need `.parseAsync`/`.safeParseAsync` — a sync `.parse` on a schema with `.refine(async …)` throws at runtime.
- Zod validates but does **not** sanitize HTML — a valid string can still be an XSS payload. Encode at the sink.

⚠ **Zod coercion footguns** (`z.coerce.*` wraps the JS global constructor):

- `z.coerce.number()` → `Number(x)`: `Number("")===0`, `Number(" ")===0`, `Number(null)===0`, `Number([])===0`. An empty form field becomes `0`, not an error. Prefer `z.string().regex(/^\d+$/).transform(Number)` or guard emptiness first.
- `z.coerce.boolean()` → `Boolean(x)`: **any non-empty string is `true`, including `"false"` and `"0"`.** Use `z.enum(["true","false"]).transform(v => v === "true")` (or `z.stringbool()` in Zod 4).
- HTML forms send `""`, never `null`/`undefined`. `.optional()` (accepts `undefined`), `.nullable()` (accepts `null`), and `.default()` are distinct — an empty text input is `""`, so add `.transform(v => v || undefined)` or `z.literal("").or(realSchema)`.

## Pydantic v2 (Python)

⚠ **v1→v2 migration** — v1 idioms silently break or emit deprecation warnings:

| v1 | v2 |
| --- | --- |
| `@validator('f')` | `@field_validator('f')` + `@classmethod` |
| `@root_validator` | `@model_validator(mode="before"\|"after")` |
| `class Config:` | `model_config = ConfigDict(...)` |
| `constr(regex=...)` | `constr(pattern=...)` / `Annotated[str, StringConstraints(pattern=...)]` |
| `anystr_strip_whitespace` | `str_strip_whitespace` |
| list `max_items` | `max_length` |
| `.dict()` / `.parse_obj()` | `.model_dump()` / `.model_validate()` |

```python
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, StringConstraints, field_validator

Password = Annotated[str, StringConstraints(min_length=12, max_length=128)]

class CreateUser(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")  # extra="forbid" blocks overposting

    email: EmailStr
    password: Password
    role: Literal["user", "admin", "moderator"] = "user"
    age: Optional[Annotated[int, Field(ge=13, le=150)]] = None
    tags: Annotated[list[str], Field(max_length=10)] = []

    @field_validator("email")
    @classmethod
    def lower(cls, v: str) -> str:
        return v.lower()
```

⚠ **v2 coercion (lax mode, the default):** numeric strings coerce (`"123" → 123`), and `float → int` succeeds only with no fractional part. To reject cross-type coercion use `ConfigDict(strict=True)` or per-field `Field(strict=True)`. `bool` in lax mode accepts `"true"/"yes"/"on"/1` etc. — surprising for API inputs; use strict or `Literal`.

- `extra="forbid"` on request models — default `extra="ignore"` silently drops unknown keys (typos pass, attacker fields ignored but not flagged).
- `EmailStr` needs `email-validator` installed; `ValidationError.errors()` gives structured `loc`/`msg`/`type` for 422 responses.
- `mode="before"` validators run on raw input (coerce/normalize); `mode="after"` run on the typed value (cross-field checks).

## JSON Schema / Ajv (language-agnostic)

Use when the contract must be shared across languages or stored as data (OpenAPI, config schemas).

```typescript
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);
const validate = ajv.compile(schema); // compile ONCE at startup, reuse; compiling per-request is a major perf cliff
if (!validate(data)) console.log(validate.errors); // {instancePath, keyword, params, message}
```

⚠ Gotchas:

- **`additionalProperties: false` is local to the schema object** and can be awkward with `allOf`/`anyOf`/`$ref`. In Draft 2019-09/2020-12 schemas, prefer `unevaluatedProperties: false` at the composed-object boundary; verify that the selected validator and OpenAPI dialect support it.
- Do not enable `coerceTypes`, `removeAdditional`, or `useDefaults` for untrusted API input by default: they mutate input and can silently turn malformed input into accepted data. Parse/normalize explicitly after validation when the contract intentionally permits coercion or defaults.
- `format` (email, uri, date-time) requires `ajv-formats`; unknown formats are ignored silently unless `strict: true`.

## Type coercion pitfalls (cross-cutting)

| Input | JS `Number()` | Note |
| --- | --- | --- |
| `""`, `" "`, `null`, `[]` | `0` | empty field silently becomes zero |
| `"123abc"`, `undefined`, `{}` | `NaN` | `NaN` passes `typeof === "number"` |
| `"0x1F"`, `"1e3"` | `31`, `1000` | hex/exponent accepted |

- `parseInt("123px") → 123` (lenient), `parseInt("") → NaN`. `parseFloat` similar. Prefer strict regex + explicit conversion for untrusted numerics.
- Ints beyond `Number.MAX_SAFE_INTEGER` (2^53) lose precision — validate large IDs/money as strings (`BigInt`, decimal).
- Empty string vs null vs absent: three distinct states. Decide per field which are allowed; don't let coercion collapse them.

## Sanitization and output encoding

**Encode at the sink, for the sink's context. One escaper does not fit all contexts.**

```typescript
import DOMPurify from "dompurify";      // browser: window; server: DOMPurify(new JSDOM("").window)
// Rendering user HTML (rich text): sanitize with an ALLOWLIST of tags/attrs
const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li"],
  ALLOWED_ATTR: ["href"],
  ALLOW_DATA_ATTR: false,
});
```

Context-dependent encoding (choose by *where the value lands*):

| Sink | Escape | Why hand-rolling fails |
| --- | --- | --- |
| HTML text | `&<>"'` → entities | — |
| HTML attribute | entity-encode + **always quote** | unquoted attr breaks on space/`/` |
| `<script>` / JS string | `\xHH` for `< > & / ' "` | `</script>` in a JS string ends the tag |
| URL component | `encodeURIComponent` | but `javascript:` scheme still executes — allowlist scheme |
| CSS value | `\HH` hex (space-terminated) | `expression()`/`url()` injection |
| SQL | **parameterized queries** | escaping is not a substitute |
| Shell | avoid; pass argv arrays, never a string | quoting is unwinnable |

⚠ Prefer framework auto-escaping (React JSX, Jinja2 autoescape, template engines) over manual encoders. Manual encoding is for the gaps (`dangerouslySetInnerHTML`, `|safe`, building HTML strings). React escapes text but **not** `href="javascript:…"`, `dangerouslySetInnerHTML`, or `<script>` content.

**Never build SQL/HTML/shell by string concatenation.** Parameterized queries for SQL; argv arrays for shells; DOM APIs / templating for HTML.

**Path traversal:** canonicalize then confine.

```typescript
import path from "node:path";
const resolved = path.resolve(baseDir, userPath);
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) throw new Error("traversal");
```

⚠ The naive `resolved.startsWith(baseDir)` check has a prefix bug: `/srv/data-evil` starts with `/srv/data`. Append the separator (as above). Decode percent-encoding before resolving.

## Security-focused validation

- **XSS:** output-encode at every sink (above); sanitize stored HTML with an allowlist; set CSP as defense-in-depth. Validation alone does not stop XSS.
- **Injection (SQL/NoSQL/LDAP/command):** parameterize / bind; never interpolate. For NoSQL (Mongo), reject object-typed values where a scalar is expected (`{"$gt": ""}` operator injection).
- **Mass assignment / overposting:** `.strict()` (Zod) / `extra="forbid"` (Pydantic) / explicit field allowlists. Never bind a request body straight onto an ORM model — an attacker sets `isAdmin`/`role`.
- **ReDoS:** user-supplied *or* poorly written regexes with nested/overlapping quantifiers (`(a+)+`, `(.*)*`, `(a|a)*`) backtrack super-linearly → CPU DoS. Mitigate: avoid nested quantifiers, anchor patterns, cap input length before matching, and for untrusted patterns use a linear engine (Google RE2 / `re2` bindings) or a match timeout. Classic offender: catastrophic email/whitespace regexes.
- **DoS via size/depth:** enforce max body bytes (server + framework), max array length, max object nesting depth, and max string length *before* deep validation. JSON parsers don't bound depth by default → "billion laughs"-style expansion and stack exhaustion.
- **Canonicalization attacks:** Unicode NFC/NFKC normalize, casefold, decode, and resolve *before* allowlist checks (see Core principles).
- **File uploads:** validate by *content* (magic bytes / sniff), not just extension or client `Content-Type` (both attacker-controlled); cap size; store outside webroot; generate server-side filenames.

## Data pipeline validation

```python
import great_expectations as gx
validator = gx.from_pandas(df)
validator.expect_column_values_to_not_be_null("email")
validator.expect_column_values_to_be_unique("email")
validator.expect_column_values_to_be_in_set("status", ["active", "inactive", "pending"])
validator.expect_column_values_to_be_between("age", 0, 150)
result = validator.validate()
if not result.success:
    raise ValueError([r for r in result.results if not r.success])
```

- Validate **at ingestion** and fail loudly; a silently-wrong pipeline corrupts every downstream table.
- Add freshness/recency checks (dbt `dbt_utils.recency`) — stale-but-valid data is a common silent failure.
- Track row-count deltas and null-rate deltas between runs, not just absolute thresholds.

## ML feature validation (train/serve consistency)

Guard against the drift that silently degrades models:

- **Schema parity:** serving features must match training columns, dtypes, and order. A renamed/missing column often defaults to null → garbage predictions with no error.
- **Distribution drift:** compare serving vs training via PSI or KS test per feature; alert on shift.
- **Categorical drift:** reject/flag unseen categories (encoders map them to 0/UNK, skewing output).
- **Null-rate spikes** and **range violations** (values outside training min/max) — clamp or reject per policy.
- **train/serve skew:** the same transformation code must run in both paths; fit scalers/encoders on training data only and persist them (fitting at serve time is leakage + skew).

## Checklists

Boundary validation — verify before done:

- [ ] Every external input (HTTP body/query/params, headers, queue msg, file, CLI arg, cross-service payload) is parsed into a typed value at the boundary
- [ ] Validator returns typed data, not a bool (parse-don't-validate)
- [ ] Unknown keys rejected on request bodies (`.strict()` / `extra="forbid"`) — no overposting
- [ ] Length/size/array-length/depth limits set; max request-body bytes enforced at the server
- [ ] Coercion audited: empty-string→0, `"false"`→true, large-int precision handled
- [ ] Allowlists (enums, char classes, known hosts/schemes), not denylists
- [ ] Canonicalize (Unicode NFC, path normalize, decode) *before* validating
- [ ] Server-side validation present even where the client already validates

Output/security — verify before done:

- [ ] Output encoded per sink (HTML/attr/JS/URL/CSS); framework auto-escaping on, manual only in gaps
- [ ] SQL parameterized; shells use argv arrays; no string-concatenated queries/commands
- [ ] Stored HTML sanitized with an allowlist (DOMPurify); CSP set
- [ ] No user-controlled or catastrophically-backtracking regex on unbounded input (ReDoS)
- [ ] File uploads validated by content, size-capped, stored outside webroot with server-generated names
- [ ] Error messages are actionable but leak no internals (stack traces, SQL, paths) in production

