Validate at the Boundary

One schema per endpoint, rejecting anything unexpected — the control that quietly prevents half the categories in this course.

Validation is not a nicety that produces friendly error messages. It is the control that stops mass assignment, blunts injection, bounds resource consumption, and turns a hostile payload into a 400 before it reaches a single line of business logic.

Allowlist, always

Define what is acceptable and reject everything else. A denylist — "strip <script>, block ' OR 1=1" — is a list of the attacks you thought of, and attackers are creative about encoding.

Reject unknown fields, do not strip them

Silently dropping an unexpected field hides both attacks and client bugs. An explicit 400 naming the unexpected key surfaces a probe immediately, and tells an honest client exactly what it got wrong.

Validate every dimension

DimensionWhy
Type{"id": {"$gt": ""}} is NoSQL injection — the field was supposed to be a string
Lengtha 10MB name field is a memory and storage problem
Rangequantity: -5 refunds money; limit: 1e9 takes the database down
Formata uuid, an email, an enum — not "any string"
Cardinalityan array of 100,000 ids is a denial of service
Depthdeeply nested JSON exhausts the parser before your code runs

Where to validate

At the boundary, once, before anything else touches the data — and then use the parsed result, not the raw body. Validating req.body and then continuing to read req.body defeats the exercise entirely.

Do not validate as a substitute for escaping

Validation reduces the surface; parameterised queries and correct output encoding are what actually prevent injection. A name field legitimately contains an apostrophe, and your validator must allow it. The query must survive it.

Bound the request before parsing

Body size limits, JSON depth limits and array length limits belong before the parser. A 500MB body is rejected by a limit, not by a schema — the schema never gets to run.

Example

Example · javascript
import { z } from 'zod';

const createOrderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),                    // format
    quantity: z.number().int().min(1).max(100),      // type + range
  })).min(1).max(50),                                // cardinality
  note: z.string().max(500).optional(),              // length
  couponCode: z.string().regex(/^[A-Z0-9]{4,16}$/).optional(),
}).strict();                                         // ← REJECT unknown keys

// Note what is absent: price, total, discount, userId, tenantId, status.
// Anything the server derives is not accepted from the client at all.

When to use it

  • A NoSQL injection is stopped because the schema required a string and the attacker sent an object.
  • A 400 naming an unexpected field reveals a client bug on the day it ships, instead of silently discarding data for a month.
  • An array of 200,000 ids is rejected by a cardinality limit before it can generate a query that locks the database.

More examples

A validation middleware worth reusing

Sort and order columns cannot be bound as query parameters, so an enum that maps to known column names is the correct pattern rather than escaping.

Example · javascript
import { z } from 'zod';

export function validate({ body, query, params }) {
  return (req, res, next) => {
    const results = {};

    for (const [key, schema] of Object.entries({ body, query, params })) {
      if (!schema) continue;
      const parsed = schema.safeParse(req[key]);
      if (!parsed.success) {
        return res.status(400).json({
          error: 'validation_failed',
          location: key,
          issues: parsed.error.issues.map((i) => ({
            path: i.path.join('.') || '(root)',
            code: i.code,
            message: i.message,
          })),
        });
      }
      results[key] = parsed.data;
    }

    // CRITICAL: replace with the PARSED values. Coercion, defaults and the
    // removal of unknown keys only take effect if downstream code reads these.
    Object.assign(req, results);
    next();
  };
}

// Query strings are all strings — coerce explicitly and bound the result.
const listQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().max(200).optional(),
  status: z.enum(['open', 'paid', 'void']).optional(),
  sort: z.enum(['createdAt', 'total']).default('createdAt'),   // never raw SQL
  order: z.enum(['asc', 'desc']).default('desc'),
}).strict();

app.get('/api/invoices', auth, validate({ query: listQuery }), async (req, res) => {
  // req.query.limit is a NUMBER, bounded to 100, with a default applied.
  // req.query.sort is one of two literals, so it is safe to interpolate.
  const rows = await db('invoices')
    .where({ user_id: req.user.id })
    .modify((q) => req.query.status && q.where({ status: req.query.status }))
    .orderBy(req.query.sort, req.query.order)
    .limit(req.query.limit);

  res.json({ data: rows.map(InvoiceSerializer.public) });
});

// The enum on `sort` is doing security work: it is the only safe way to let a
// client influence an ORDER BY clause, which cannot be parameterised.

Bounding the request before the parser runs

Capturing rawBody in the verify hook is the standard way to keep webhook signature verification possible while still using a JSON body parser.

Example · javascript
// A schema cannot protect you from a payload that kills the parser first.

// 1. Body size — the outermost limit
app.use(express.json({
  limit: '100kb',                       // most APIs need far less than 1mb
  strict: true,                         // only objects and arrays at the root
  verify: (req, res, buf) => { req.rawBody = buf; },   // for webhook signatures
}));

// Per-route override where a larger body is genuinely needed
app.post('/api/documents', express.json({ limit: '5mb' }), createDocument);

// 2. JSON depth — {"a":{"a":{"a": ... }}} 50,000 deep exhausts the stack
function depthLimit(max = 20) {
  return (req, res, next) => {
    const depth = (v, d = 0) => {
      if (d > max || v === null || typeof v !== 'object') return d;
      return Math.max(d, ...Object.values(v).map((c) => depth(c, d + 1)));
    };
    if (depth(req.body) > max) {
      return res.status(400).json({ error: 'payload_too_deep' });
    }
    next();
  };
}

// 3. Key count — {"a1":1,"a2":1, ... } with a million keys
function keyLimit(max = 200) {
  return (req, res, next) =>
    Object.keys(req.body ?? {}).length > max
      ? res.status(400).json({ error: 'too_many_fields' })
      : next();
}

app.use(depthLimit(20), keyLimit(200));

// 4. Prototype pollution — reject the poisoning keys during parse
app.use(express.json({
  reviver: (key, value) =>
    ['__proto__', 'constructor', 'prototype'].includes(key) ? undefined : value,
}));

// 5. Content-Type — refuse what you do not handle
app.use((req, res, next) => {
  if (!['POST', 'PUT', 'PATCH'].includes(req.method)) return next();
  const ct = (req.get('content-type') ?? '').split(';')[0].trim();
  if (!['application/json', 'multipart/form-data'].includes(ct)) {
    return res.status(415).json({ error: 'unsupported_media_type' });
  }
  next();
});

// Ordering matters: size → content-type → parse → depth → schema.
// Each layer protects the one after it.

The same discipline in PHP and Python

extra='forbid' in Pydantic and the withValidator check in Laravel are the equivalents of Zod's .strict() — none of the three reject unknown keys by default.

Example · php
<?php
// Laravel — the rules ARE the allowlist, and validated() returns ONLY them.
public function store(Request $request)
{
    $data = $request->validate([
        'items'              => ['required', 'array', 'min:1', 'max:50'],
        'items.*.productId'  => ['required', 'uuid'],
        'items.*.quantity'   => ['required', 'integer', 'min:1', 'max:100'],
        'note'               => ['nullable', 'string', 'max:500'],
        'couponCode'         => ['nullable', 'string', 'regex:/^[A-Z0-9]{4,16}$/'],
    ]);

    // ✅ $data contains ONLY the validated keys — this is the allowlist.
    // ❌ $request->all() would include everything the attacker sent.
    $order = Order::create([
        ...$data,
        'user_id' => $request->user()->id,      // from the credential, not input
        'total'   => $this->calculateTotal($data['items']),   // server-computed
    ]);

    return new OrderResource($order);           // explicit output shape
}

// Reject unknown keys explicitly — Laravel ignores them by default.
public function rules(): array { /* ... */ }
public function withValidator($validator): void
{
    $validator->after(function ($v) {
        $unknown = array_diff(array_keys($this->all()), array_keys($this->rules()));
        if ($unknown) {
            $v->errors()->add('body', 'Unexpected fields: ' . implode(', ', $unknown));
        }
    });
}

# ------------------------------------------------------------------
# Python — Pydantic v2
# from pydantic import BaseModel, Field, ConfigDict
#
# class OrderItem(BaseModel):
#     model_config = ConfigDict(extra='forbid')     # ← reject unknown keys
#     product_id: UUID
#     quantity: int = Field(ge=1, le=100)
#
# class CreateOrder(BaseModel):
#     model_config = ConfigDict(extra='forbid')
#     items: list[OrderItem] = Field(min_length=1, max_length=50)
#     note: str | None = Field(default=None, max_length=500)
#
# @app.post('/api/orders')
# async def create(order: CreateOrder, user = Depends(current_user)):
#     ...   # FastAPI validates before the handler body runs at all

Discussion

  • Be the first to comment on this lesson.