Security

input-validation-layer

$npx skills add blunotech-dev/agents --skill input-validation-layer

Add a centralized input validation layer at all system entry points. Use this skill when the user mentions input validation, schema validation, request validation, Zod, Joi, Yup, sanitizing API input, validating webhooks, form submissions, or asks "how do I validate incoming data?" or "where should validation live?".

namedescriptioncategory
input-validation-layerAdd a centralized input validation layer at all system entry points. Use this skill when the user mentions input validation, schema validation, request validation, Zod, Joi, Yup, sanitizing API input, validating webhooks, form submissions, or asks "how do I validate incoming data?" or "where should validation live?".Security

Input Validation Layer

Validation belongs at every entry point — API routes, webhooks, queue consumers, cron job inputs — before any business logic runs. One missed entry point is all an attacker needs.


Principles

  • Validate at the boundary — before the data touches your app logic or DB
  • Allowlist, not blocklist — define the exact shape you accept; reject everything else
  • Never trust — headers, query params, URL params, request body, cookies, webhook payloads
  • Fail closed — unknown/extra fields stripped or rejected; never passed through silently
  • Single schema = source of truth — same schema used for validation, TypeScript types, and docs

Schema Design (Zod, used throughout)

import { z } from 'zod';

// Define once — derive types from it
export const CreateInvoiceSchema = z.object({
  amount:      z.number().int().positive().max(1_000_000_00),  // cents, no float drift
  currency:    z.enum(['USD', 'EUR', 'GBP']),
  description: z.string().min(1).max(500).trim(),
  due_date:    z.string().datetime(),                           // ISO 8601 only
  recipient:   z.object({
    email: z.string().email().toLowerCase(),
    name:  z.string().min(1).max(100).trim(),
  }),
});

// TypeScript type derived — no duplication
export type CreateInvoice = z.infer<typeof CreateInvoiceSchema>;

Strip unknown fields — never let extra fields through to the ORM:

// .strip() is Zod's default — unknown keys silently dropped
// .strict() rejects unknown keys entirely (good for internal APIs)
const StrictSchema = CreateInvoiceSchema.strict();

Centralizing Validation: Middleware Pattern

Write once, apply to any route.

// middleware/validate.ts
import { z, ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';

type Target = 'body' | 'query' | 'params';

export function validate(schema: ZodSchema, target: Target = 'body') {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[target]);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        issues: result.error.issues.map(i => ({
          field: i.path.join('.'),
          message: i.message,
        })),
      });
    }
    req[target] = result.data; // replace with parsed/coerced/stripped data
    next();
  };
}

// Usage
app.post('/invoices',
  requireAuth,
  validate(CreateInvoiceSchema, 'body'),
  validate(z.object({ org_id: z.string().uuid() }), 'params'),
  createInvoiceHandler,
);

Entry Points to Cover

API routes (above pattern)

Query params — often forgotten

const PaginationSchema = z.object({
  page:  z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort:  z.enum(['created_at', 'amount', 'due_date']).default('created_at'),
  order: z.enum(['asc', 'desc']).default('desc'),
});

// z.coerce converts "?page=2" (string) → number automatically
app.get('/invoices', requireAuth, validate(PaginationSchema, 'query'), listInvoices);

Webhooks — validate payload + verify signature

const StripeWebhookSchema = z.object({
  type: z.string(),
  data: z.object({ object: z.record(z.unknown()) }),
});

app.post('/webhooks/stripe',
  validateStripeSignature,     // verify HMAC before parsing — rejects spoofed payloads
  validate(StripeWebhookSchema),
  handleStripeWebhook,
);

function validateStripeSignature(req, res, next) {
  try {
    // stripe.webhooks.constructEvent needs raw body — use express.raw() on this route
    stripe.webhooks.constructEvent(req.rawBody, req.headers['stripe-signature'], WEBHOOK_SECRET);
    next();
  } catch { res.status(400).send('Invalid signature'); }
}

Queue / worker consumers

// Don't trust queue messages any more than HTTP requests
async function processJob(rawPayload: unknown) {
  const payload = SendEmailSchema.parse(rawPayload); // throws on invalid
  await sendEmail(payload);
}

Environment / config on startup

const EnvSchema = z.object({
  DATABASE_URL:    z.string().url(),
  JWT_PRIVATE_KEY: z.string().min(1),
  STRIPE_SECRET:   z.string().startsWith('sk_'),
  PORT:            z.coerce.number().default(3000),
});

const env = EnvSchema.parse(process.env); // fail fast at startup, not at runtime
export default env;

Non-Obvious Things to Validate

InputWhat to enforce
Numeric IDs from URL.uuid() or regex — never trust parseInt alone
Enum fields.enum([...]) — not just z.string()
Dates.datetime() or .coerce.date() — not raw strings used in queries
Currency/moneyInteger cents — reject floats to avoid drift
Redirect URLsValidate scheme + origin (see xss-prevention / idor-fix skills)
File uploadsMIME type, extension, size limit — all three; MIME alone is spoofable
Free-text fields.max() always — unbounded strings can fill DB / trigger DoS
Arrays.array().min(1).max(50) — unbounded arrays = DoS vector

Validation vs Sanitization

ValidationSanitization
PurposeReject invalid inputTransform input to safe form
WhenAlways, at entry pointOnly when format must be preserved
ExamplesReject non-email strings.trim(), .toLowerCase(), strip control chars

Prefer rejection over transformation for security-sensitive fields. Transforming input silently (e.g., truncating to max) hides bugs and can introduce unexpected behavior.

.trim() in schema is fine for UX. Never silently truncate IDs, tokens, or enum values.


Error Response Shape

Consistent validation error shape across all endpoints:

{
  "error": "Validation failed",
  "issues": [
    { "field": "recipient.email", "message": "Invalid email" },
    { "field": "amount", "message": "Number must be positive" }
  ]
}

Don't leak internal details — schema paths are fine; stack traces are not.


Audit Checklist

  • Every route has a schema for body, query params, and URL params
  • Unknown/extra fields stripped or rejected — not passed to ORM
  • Query param numbers use z.coerce — no raw string-to-number conversion in handlers
  • Webhook routes verify signature before parsing payload
  • Queue/worker consumers validate payloads with same rigor as HTTP routes
  • Env vars validated at startup with a schema
  • All string fields have .max() — no unbounded strings
  • All arrays have .max() — no unbounded arrays
  • Enum fields use .enum() — not just z.string()
  • Money/currency stored as integers — no floats
  • Validation errors return consistent shape without stack traces