Skip to main content

Input Validation

The classic engineering mistake is to write business logic that assumes its input is well-formed, then discover later that the input was an empty string, a negative number, an SQL fragment, an HTML tag, or a 50 MB JSON document. Most security holes start as "the input was not what we thought it was."

Input validation is the discipline of refusing malformed input at the boundary of the system, before any business logic runs. This page explains how Dashify validates the three big categories of input, request bodies, request parameters, and user-uploaded content.

The boundary, explicitly

There are two validation layers. Zod validates the shape of incoming JSON at the HTTP boundary. Mongoose validates the shape of documents at save time. Each catches different bugs.

Zod catches bad input, a missing field, the wrong type, an out-of-range number. It runs before any business logic and rejects the request with a 400.

Mongoose catches programmer mistakes, code that assembled a document with a wrong shape internally. It runs at save time and surfaces the bug.

Both are needed. Trusting just one is brittle.

Zod at the HTTP boundary

Zod is a TypeScript-first schema library. A schema describes the expected shape of an object, and the library produces both a runtime validator and a type. The same schema serves both purposes.

A typical route's validation might say, in plain English:

"The body must be an object with these fields: email (a string in email format), password (a string between 8 and 128 characters), rememberMe (a boolean, optional, defaults to false). Anything else, reject."

If the request matches, the handler receives a typed, validated object. If not, the API returns 400 with a list of validation errors.

Zod's strength is composition. We can define small schemas, an email, a UUID, a date, a money amount, and reuse them across routes. New routes inherit the same precise definitions automatically.

What "the wrong type" really catches

You might wonder how often type mistakes happen in real life. Often. A few examples:

  • A buggy client sends userId: 12345 instead of userId: "12345". Without validation, the server might accept the number, pass it into a Mongo query, and silently miss the document.
  • An attacker sends tenantId: { $ne: null }. Without validation, this Mongo operator could be injected and return every tenant's documents (NoSQL injection). With validation, the field must be a string, the object is rejected.
  • A buggy retry layer sends an array of values instead of a single value. Without validation, downstream code might .split on the array and crash. With validation, the request is cleanly rejected.

Strict typing at the boundary is not a developer convenience. It is a security control.

Length limits, because attacks are large

Every string field in Dashify's Zod schemas has a maximum length. Comments cannot be a megabyte. Names cannot be 10 KB. Search queries cannot be a screenful. The limits are generous enough never to bother real users and tight enough to bound the resource cost of a single request.

Without these limits, an attacker can submit comically large inputs to exhaust memory, fill the database, or trigger pathological regex matches (ReDoS).

Body size limits

Express's body parser is configured with a global maximum body size, 1 MB for JSON, larger for file uploads (which use multipart and are routed separately). Anything bigger than 1 MB of JSON is rejected at the parser level before Zod even sees it.

For file uploads, Multer has its own per-route limits, typically 25 MB. Files larger than the limit are rejected with a 413 Payload Too Large.

Sanitisation vs validation

Validation says "this input is acceptable / unacceptable; reject the unacceptable." Sanitisation says "this input contains dangerous content; let me clean it before storing." Both are useful. Dashify uses validation for structured fields (numbers, dates, ids) and sanitisation for free-form HTML.

The knowledge base lets users write rich-text articles. The CKEditor on the client produces HTML. Before saving, the HTML is run through DOMPurify which strips out script tags, event handlers (onclick=...), and dangerous attributes. What remains is a safe subset that renders the user's intent without any code-execution risk.

DOMPurify also runs at render time, not just at save time. Even if a malicious payload somehow lands in the database (perhaps through a different code path), it cannot escape into the user's browser, the renderer purifies it again on the way out.

NoSQL injection

MongoDB queries are objects, not strings. That removes the SQL-injection vector that haunts SQL databases, there is no string concatenation to subvert. But there is a related vector: operator injection.

If a request is supposed to send email: "alice@example.com" and the API plugs it directly into a query, an attacker who sends email: { $gt: "" } instead can match any email at all. That single character expansion turns "find a specific user" into "find any user."

The defence is exactly the validation we are talking about. Zod requires email to be a string. The object { $gt: "" } is not a string and gets rejected at the boundary.

Path parameter validation

URL path parameters (/api/v1/users/:id) are strings. They are also Zod-validated, typically a strict regex like "must be a 24-character hex string" for a Mongo _id, or a UUID, or a slug. An invalid id never reaches the handler.

Where validation does not live

A common mistake is to put validation in the client and assume the server is safe. The client cannot be trusted. Anyone can submit a request with curl. Validation has to live on the server or it does not exist.

The client also has validation, for instant feedback to legitimate users, that is a usability feature. It is not a security feature.

Centralised error responses

Validation errors all flow through a single error handler that produces a consistent shape:

400 Bad Request
{ "error": "VALIDATION_FAILED", "details": [ { "field": "email", "message": "Invalid email" }, ... ] }

The client knows how to render this. The browser app shows inline form errors. Scripts get a parseable JSON. There is no "sometimes a string, sometimes an object", every validation failure looks the same.

Key takeaways

  • Validation runs at the HTTP boundary with Zod and at the save boundary with Mongoose. Both layers are needed.
  • Strict typing rejects malformed input before any business logic runs, a security control, not just a convenience.
  • Every string field has a length limit; bodies have a size limit; uploads have a per-route limit.
  • Free-form HTML is sanitised with DOMPurify at both save and render time.
  • Validation lives on the server. Client-side validation is for usability, not security.