Get listed

Input validation: allowlist type and length, then encode

A kitchen colander over a bowl with a coral pebble through.

Input validation is checking shape and type before the value reaches an interpreter or a query.

A required attribute on an HTML email field is not a control. curl skips it. A Zod or similar schema on the server is a control, if you refuse unknown keys and you do not later concatenate the valid string into SQL.

The usual mistake is validating for UX and authorizing for hope. A well-formed invoice id that belongs to someone else is still an IDOR.

This page is where validation belongs, what it cannot do, and the libraries that make the happy-path-only bug harder to ship.

Zod 4.4.3 published on 4 May 2026. A required attribute on an email field is still not a control. curl skips the browser. CWE-20 is Improper Input Validation: the product does not validate or incorrectly validates input that can affect a downstream component.

Then it praised cleaner analytics data. That is not a control. The control is a schema you parse before the handler touches an interpreter, plus the sink rule the schema cannot replace.

OWASP Input Validation Cheat Sheet. It says validation should not be the primary method of preventing XSS and SQL injection. Pair this page with XSS, injection, and path traversal.

The allowlist is door one. Encode at the sink is door two. A request that skips the form still has to walk through the second frame.

SecureCoding

CWE-20 is a door check, not a sink

MITRE describes CWE-20 as missing or wrong checks on input that later changes control flow or data flow. The check belongs as soon as the value leaves the untrusted boundary: query, body, header, cookie, file name, webhook. Semantic rules sit next to syntactic ones. A start date after an end date is a business miss. A string that is not an integer is a type miss.

What the door check cannot do:

  • It cannot make <em> safe in HTML. A comment may legally contain that text.
  • It cannot replace a bind. An allowlisted integer still goes in as $1, not as string add.
  • It cannot pin a directory. A file token is safer than a user path. If you must resolve a name, that is the traversal page.

Denylist filters for ', 1=1, or a script tag are the usual miss. They block O'Brien and they miss the next encoding. Allowlist what you will accept. Reject the rest with 400. Log the field name, not the raw mystery string if it looks like a secret.

Closed set, type, then length

Three questions for every field, in that order:

  1. Which values are legal? A role is reader or editor. A country is an ISO code you listed. A sort key is created_at or cents. Map the token to the identifier you wrote. Never interpolate the client string into ORDER BY.
  2. What type is it? Parse invoiceId as an integer or a UUID. A JSON object that arrives where you expected a string is a Mongo operator story. Reject a non-string before it becomes a filter.
  3. How long? displayName max 80. Email local part max 63, total max 254, which is the cheat sheet’s practical cap. A 2 MB bio is a memory and log problem before it is an XSS problem.

Free-form Unicode is the field people try to "sanitize." Normalize first. Allow letters, marks, and the few punctuation marks you named. Then escape on the way out. The cheat sheet’s Unicode section is the same call: users may type an apostrophe. Your job is to carry it as data.

FieldDoor checkSink still
roleenum reader editorbind, never concatenate
displayNamestring, 1 to 80HTML encode or textContent
invoiceIdUUID or intquery with userId
file tokenid you mintedrealpath plus prefix

The browser check is UX

<input type="email" required maxlength="254"> saves a round trip. A user sees a red ring before the POST. That is kindness. It is not a security boundary. DevTools, a proxy, a native client, and curl all skip it. Hidden fields and disabled controls are the same story. The server must parse again.

HTML5 constraint validation is Baseline. Use it. Do not cite it in a threat model as the control. The cheat sheet’s client-versus-server section is blunt: JavaScript can be turned off or rewritten. Do both. Trust the server copy.

The interpreter still needs escaping

A perfect allowlist still lets a user type <em>hi</em> if you allow those characters, and it still lets them type a quote if names may contain one. The interpreter that must not honor that shape is the sink.

HTML body: textContent, or the template engine’s default escape. The XSS page is Trusted Types, setHTML, and a CSP nonce. Do not invent a regex that strips tags and then assign innerHTML.

SQL: a parameter. jiggawatts’s point is the one I keep: the client library sends the statement and the values on separate channels. String add with escaped quotes is templating. The injection page is the ORM hatches: literal, whereRaw, queryRawUnsafe.

OS command: do not. If you must spawn, execFile with an argument array and --. A validated filename can still start with a dash.

import { execFile } from "node:child_process";

function previewPdf(absPath) {
 return new Promise((resolve, reject) => {
 execFile("pdftotext", ["--", absPath, "-"], (err, stdout) => {
 if (err) reject(err);
 else resolve(stdout);
 });
 });
}

Paths and uploads

A user string in path.join(FILE_ROOT, req.query.file) is CWE-22. The traversal page is the full control: mint a token, map it to a name you chose, realpath the result, require the prefix plus a trailing separator. path.normalize is lexical. Go 1.24’s os.Root still needed 1.24.3 after CVE-2025-22873. that NVD page for the suffix miss.

Uploads: you pick the stored name. Allowlist the detected type, not the client extension. Cap the byte size before you buffer the whole body. Do not exec the bytes. Serve images with a real image/jpeg or image/png you measured, not a name the client sent. Turn execution off in the upload directory.

import path from "node:path";
import fs from "node:fs/promises";

const FILE_ROOT = "/var/app/files";
const ALLOWED = new Map([
 ["inv-a1", "a1.pdf"],
 ["inv-b2", "b2.pdf"],
]);

async function openInvoice(token) {
 const name = ALLOWED.get(token);
 if (!name) return null;
 const abs = await fs.realpath(path.join(FILE_ROOT, name));
 const root = await fs.realpath(FILE_ROOT);
 const prefix = root.endsWith(path.sep) ? root : root + path.sep;
 if (!abs.startsWith(prefix)) return null;
 return abs;
}

Identifiers stay FILE_ROOT, ALLOWED, token, and abs. A resolved file that belongs to another tenant is still an object-authz miss. Validation of the token does not replace that query.

Parse the body with Zod

As of 4 May 2026, npm registry: 4.4.3. Zod 4 promotes email to z.email(). z.string().email() still runs and is deprecated. z.object strips unknown keys by default. Use z.strictObject when an extra key is an alarm, which it is on a role or a price.

import { z } from "zod";

const ProfileSchema = z.strictObject({
 email: z.email(),
 displayName: z.string().min(1).max(80),
 role: z.enum(["reader", "editor"]),
});

app.post("/profile", (req, res) => {
 const parsed = ProfileSchema.safeParse(req.body);
 if (!parsed.success) {
 req.log.warn({ fields: parsed.error.issues.map((i) => i.path) }, "profile reject");
 res.status(400).send("Invalid profile");
 return;
 }
 const { email, displayName, role } = parsed.data;
 res.locals.profile = { email, displayName, role };
 // bind these. do not concatenate displayName into SQL or HTML.
});

Parse once. Pass parsed.data downstream. Do not read req.body.displayName again after a successful parse. A later middleware that trusts the raw body undoes the door. The same schema can run in the browser for UX. The handler copy is the one that counts.

Regex for structured fields: anchor ^ and $. Do not use an open . that eats the line. ReDoS is a real CPU burn on a naive email pattern. Prefer z.email() or a bounded length plus a simple shape, then let the mailer reject the rest.

Prove the parse

You are proving your handler returned 400 and that the sink still encoded.

  1. POST your own /profile with role set to admin. Expect 400. A 200 means the enum was a client dropdown only.
  2. POST displayName of 8 KB. Expect 400.
  3. POST a valid profile with displayName equal to <em>x</em>. Load the profile page. Expect escaped text or a text node, not italics.
  4. POST a quote in displayName. The SQL log should show a parameter, not a broken quote in the statement text.
  5. Call your file helper with a token you did not mint. Expect a miss. Do not pass ../ into a join to "see what happens" on a shared host. Write a unit test that feeds a missing token.
curl -sS -D - -o /dev/null -X POST "https://your-app.example/profile" \
 -H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
 -H "Content-Type: application/json" \
 --data '{"email":"you@your-app.example","displayName":"Ada","role":"admin"}'
# Expect: HTTP/2 400
rg -n "req\\.body\\.|innerHTML|whereRaw|queryRawUnsafe|FILE_ROOT" \
 --glob '!node_modules'

Questions we keep getting

Is a denylist of script tags ever enough?

No. It is an extra alarm, not the door. Name the legal set, the type, and the max length. Escape HTML on the way out. A denylist that blocks O'Brien is also a product bug.

Should I validate headers and cookies too?

Yes. Treat Host, Origin, and any cookie you parse as untrusted strings. Length-cap them. Do not pass Host into a password-reset link without an allowlist of your own origins.

Does a TypeScript type replace Zod?

No. Types erase at runtime. req.body is any until you parse it. The schema is the check the process actually runs.