---
title: 'Input Validation Patterns'
source: 'https://academia.sh/en/courses/secure-coding/input-validation-patterns'
course: 'Secure Coding'
language: en
updated: '2026-09-26T03:02:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Models the entry point inventory as a data structure and derives the field point count'
    - 'Measures how many points the defense covers and leaves uncovered by sending a violation probe to each point'
    - 'Compares whether coverage stays fixed when a new point and a new field type are added to the inventory'
---

# Input Validation Patterns

Measuring how many points validation covers: a probe that breaks its own declaration is sent to each of the fifteen field points across the regional measurement network's five entry points, the number of points closed by allowlist-based validation, by the denylist, and by a hand-written check per endpoint is counted, and how coverage changes when a new endpoint and a new field type are added to the inventory is measured.

The Secure Development Lifecycle course counted which threats arose at the design stage
and which countermeasures were planned for them. Those countermeasures shared one
property: each was a decision, not a place. Once a decision turns into code, it does not
live in a single place — at every entry point in the codebase, it either holds or it does
not, independently.

This lesson counts those places. What it measures is not how validation is written — the
Server Security and Going to Production course measured the validation function itself.
What is measured here is **coverage**: how many points there are, how many the defense
covers, and how many it leaves uncovered. The unit of measure for this course is the final
count.

## Point Inventory

The regional measurement network is fictional: the subscriber portal, the meter reading
intake service, the field application, and the report generator. The unit of the
inventory is not the endpoint but the **point**: one field of one endpoint. If an
endpoint has three fields, that endpoint carries three points, because the defense
exists — or does not — per field.

**IO1 (assumption):** the inventory is complete. Every count in this lesson is relative
to this inventory and says nothing about an entry point that is not in it.

```js
// points.mjs — entry points of the regional measurement network (fictional model)
// Point = one field of one endpoint. The declaration keeps each point's constraint in one place.
export const DECLARATION = {
  "POST /subscriber":  { subscriberNo: ["code", /^A-\d{6}$/], name: ["text", 60],
                     tariff: ["set", ["residential", "commercial", "agricultural"]] },
  "GET /invoice":   { subscriberNo: ["code", /^A-\d{6}$/], period: ["code", /^\d{4}-\d{2}$/],
                     page: ["number", [1, 200]] },
  "POST /reading":  { meterNo: ["code", /^S-\d{7}$/], value: ["number", [0, 999999]],
                     source: ["set", ["field", "remote"]] },
  "POST /work-order": { subscriberNo: ["code", /^A-\d{6}$/], type: ["set", ["disconnection", "connection", "measurement"]],
                     description: ["text", 200] },
  "GET /report":    { period: ["code", /^\d{4}-\d{2}$/], region: ["set", ["north", "south"]],
                     format: ["set", ["summary", "detailed"]] },
};

export const points = (d = DECLARATION) =>
  Object.entries(d).flatMap(([endpoint, fields]) => Object.keys(fields).map((fieldName) => [endpoint, fieldName]));

const VALID = { code: (r) => (r.test("A-100432") ? "A-100432" : r.test("S-2200145") ? "S-2200145" : "2026-03"),
  text: () => "measurement note", set: (s) => s[0], number: ([lo]) => lo, list: (s) => [s[0]] };
export const validRecord = (endpoint, d = DECLARATION) =>
  Object.fromEntries(Object.entries(d[endpoint]).map(([fieldName, [type, spec]]) => [fieldName, VALID[type](spec)]));

// Probe: breaks only a single point's declaration; the other points in the same record stay valid.
const VIOLATION = { code: () => "A-1", text: (n) => "x".repeat(n + 1), set: () => "unknown",
  number: ([, hi]) => hi + 1, list: () => ["unknown"] };
export const probe = (endpoint, fieldName, d = DECLARATION) =>
  ({ ...validRecord(endpoint, d), [fieldName]: VIOLATION[d[endpoint][fieldName][0]](d[endpoint][fieldName][1]) });
```

The probe concept carries the whole measurement. The only way to ask whether a point is
covered is to send a value that breaks **that point's own declaration** and check whether
the record is rejected. The values in the probe are harmless: a short identifier, one
character too much text, a word that is not in the set, a number one above the upper
bound.

**IO2 (assumption):** a point counts as covered when its probe is rejected. Since the
probe breaks only that one point, the rejection decision can be attributed to it.

## Three Defense Forms

Three forms are laid over the same inventory. **Allowlist-based validation** derives its
decision from the declaration: an endpoint with no declaration is rejected, and so is a
field that is not in the declaration. **Denylist-based validation** rejects named shapes
and never reads the endpoint's declaration at all. The third is scattered defense: a
hand-written check per endpoint.

```js
// validator.mjs — three defense forms looking at the same set of points
import { DECLARATION } from "./points.mjs";

// Type table: the constraint types the allowlist recognizes. A new type is added here.
export const TYPE = {
  code:   (v, s) => typeof v === "string" && s.test(v),
  text:   (v, s) => typeof v === "string" && v.length <= s,
  set:    (v, s) => s.includes(v),
  number: (v, s) => Number.isInteger(v) && v >= s[0] && v <= s[1],
};

// 1) Allowlist: the decision derives from the declaration. An endpoint with no
//    declaration, a field with no declaration, and a type not in the table -- all rejected.
export function allowlist(endpoint, record, d = DECLARATION, type = TYPE) {
  const fields = d[endpoint];
  if (!fields) return "reject";
  for (const [name, v] of Object.entries(record)) {
    const f = fields[name];
    if (!f || !type[f[0]] || !type[f[0]](v, f[1])) return "reject";
  }
  return Object.keys(fields).every((f) => f in record) ? "accept" : "reject";
}

// 2) Denylist: named shapes are rejected; the endpoint's declaration is never read.
const FORBIDDEN_FIELD = ["permission", "role", "amount"], MAX_LENGTH = 200;
export function denylist(endpoint, record) {
  for (const [name, v] of Object.entries(record)) {
    if (FORBIDDEN_FIELD.includes(name)) return "reject";
    const s = typeof v === "string" ? v : String(v ?? "");
    if (s.length === 0 || s.length > MAX_LENGTH) return "reject";
  }
  return "accept";
}

// 3) Scattered defense: hand-written check per endpoint; written for three, two left empty.
export function handWritten(endpoint, record) {
  if (endpoint === "POST /subscriber") return /^A-\d{6}$/.test(record.subscriberNo ?? "") ? "accept" : "reject";
  if (endpoint === "GET /invoice") return /^A-\d{6}$/.test(record.subscriberNo ?? "") &&
    Number.isInteger(record.page) && record.page >= 1 ? "accept" : "reject";
  if (endpoint === "POST /reading") return /^S-\d{7}$/.test(record.meterNo ?? "") ? "accept" : "reject";
  return "accept";
}
```

All three forms are probed with the same fifteen probes. The fourth column keeps the
cost: which endpoint stops when called with a valid record.

```js
// coverage.mjs — each point is probed with a value that breaks its own declaration
import { DECLARATION, points, probe, validRecord } from "./points.mjs";
import { allowlist, denylist, handWritten } from "./validator.mjs";

const P = points(), EP = Object.keys(DECLARATION);
console.log(`${EP.length} entry points, ${P.length} field points, ${P.length} probes\n`);
console.log(`${"defense form".padEnd(24)}${"covered".padStart(9)}${"uncovered".padStart(11)}${"legit endpoints stopped".padStart(25)}`);
for (const [name, f] of [["allowlist", allowlist], ["denylist", denylist],
    ["hand-written check", handWritten]]) {
  const uncovered = P.filter(([e, a]) => f(e, probe(e, a)) === "accept");
  const stopped = EP.filter((e) => f(e, validRecord(e)) !== "accept").length;
  console.log(name.padEnd(24) + String(P.length - uncovered.length).padStart(9) +
    String(uncovered.length).padStart(11) + String(stopped).padStart(25));
  console.log("  uncovered per endpoint: " +
    EP.map((e) => `${e.split(" ")[1]} ${uncovered.filter(([x]) => x === e).length}`).join("  "));
}
```

```
5 entry points, 15 field points, 15 probes

defense form              covered  uncovered  legit endpoints stopped
allowlist                      15          0                        0
  uncovered per endpoint: /subscriber 0  /invoice 0  /reading 0  /work-order 0  /report 0
denylist                        1         14                        0
  uncovered per endpoint: /subscriber 3  /invoice 3  /reading 3  /work-order 2  /report 3
hand-written check              3         12                        0
  uncovered per endpoint: /subscriber 2  /invoice 2  /reading 2  /work-order 3  /report 3
```

The denylist closes **one** of the fifteen points. Which point it closes is also
incidental: the work order description's upper bound is two hundred characters, and the
list's general length rule is also two hundred, so the probe gets caught there. On the
remaining fourteen points the list knows nothing — it does not recognize identifier
format, set membership, or number range, because these are specific to the point and are
not written into the list. **The denylist's cost turned out to be zero; its narrowness is
exactly why its coverage is low.**

The hand-written check closes three points, and all three are from the same class: the
subscriber and meter identifier. Whoever wrote the check saw the first field at every
endpoint and did not see the second or third. Two endpoints have no check at all; the six
points there are uncovered from the start.

The allowlist's zero does not come from validation being written more carefully. The
declaration already carried the point's constraint; validation just reads that
declaration. **Coverage is the result of the defense deriving from the same place as the
declaration.**

## Outside the Inventory

Fifteen out of fifteen is a number that depends on IO1: the inventory was assumed
complete. Once that assumption is lifted, coverage leaks from somewhere else. The list of
endpoints registered in the code lives in the routing table; the list of declarations is
separate, and the two are not required to be equal.

**IO3 (assumption):** the routing table has been extracted from the code and contains
every registered endpoint. The declaration set, on the other hand, was written by hand.

```js
// inventory-gap.mjs — the difference between endpoints registered in the code and declared endpoints
import { DECLARATION } from "./points.mjs";
import { allowlist } from "./validator.mjs";

// Routing table (model): the endpoints actually registered in the code and the fields the handler reads.
const ROUTES = {
  "POST /subscriber": ["subscriberNo", "name", "tariff"], "GET /invoice": ["subscriberNo", "period", "page"],
  "POST /reading": ["meterNo", "value", "source"], "POST /work-order": ["subscriberNo", "type", "description"],
  "GET /report": ["period", "region", "format"],
  "POST /reading/bulk": ["fileNo", "row", "source"],           // no declaration
  "GET /subscriber/history": ["subscriberNo", "start", "end"], // no declaration
};

const undeclared = Object.keys(ROUTES).filter((e) => !DECLARATION[e]);
const pointCount = (l) => l.reduce((t, e) => t + ROUTES[e].length, 0);
const PROBE = "x".repeat(5000);   // a length no field declaration will accept
const record = (e, broken) => Object.fromEntries(ROUTES[e].map((f) => [f, f === broken ? PROBE : "value"]));

console.log(`routing table ${Object.keys(ROUTES).length} endpoints / ${pointCount(Object.keys(ROUTES))} points, ` +
  `declaration ${Object.keys(DECLARATION).length} endpoints / ${pointCount(Object.keys(DECLARATION))} points`);
console.log(`undeclared endpoints: ${undeclared.join(", ")} -> ${pointCount(undeclared)} points\n`);

// Two policies for an undeclared endpoint.
const passThrough = (e, r) => (DECLARATION[e] ? allowlist(e, r) : "accept");
console.log(`${"undeclared endpoint policy".padEnd(29)}${"uncovered points".padStart(17)}${"legit endpoints stopped".padStart(25)}`);
for (const [name, f] of [["default reject", allowlist], ["default pass", passThrough]]) {
  const out = undeclared.flatMap((e) => ROUTES[e].map((f2) => [e, f2]))
    .filter(([e, f2]) => f(e, record(e, f2)) === "accept").length;
  const stopped = undeclared.filter((e) => f(e, record(e, null)) !== "accept").length;
  console.log(name.padEnd(29) + String(out).padStart(17) + String(stopped).padStart(25));
}
```

```
routing table 7 endpoints / 21 points, declaration 5 endpoints / 15 points
undeclared endpoints: POST /reading/bulk, GET /subscriber/history -> 6 points

undeclared endpoint policy    uncovered points  legit endpoints stopped
default reject                               0                        2
default pass                                 6                        0
```

Six of the twenty-one points have no declaration. The central pattern **cannot close
these six points on its own**; the only thing it can decide is what to do with an
endpoint that has no declaration. Default reject closes all six points and, in exchange,
stops two endpoints — one is the bulk reading upload endpoint, the other is subscriber
history. Default pass stops no legitimate work and leaves the six points in the open.

The difference between the two rows lies in silence. Under the top policy, a missing
declaration is seen on the first call; under the bottom one there is no sign at all.
**Because coverage itself is measurable, this difference can also be written down:**
comparing the two lists is a single subtraction, and it can be repeated at every change.

## When a New Point Is Added

The real test of coverage begins when the inventory grows. A new endpoint is opened for
meter-change declarations: first with three fields using only known types, then with a
fourth field of a new type — a list carrying multiple values.

**IO4 (assumption):** the new endpoint's declaration is hand-written and is assumed to be
written correctly. What is measured is not the declaration's correctness but the
defense's automatic behavior once the declaration exists.

```js
// new-point.mjs — what happens to coverage when a new endpoint and a new field type are added
import { DECLARATION, points, probe, validRecord } from "./points.mjs";
import { allowlist, denylist, handWritten, TYPE } from "./validator.mjs";

const NEW_ENDPOINT = "POST /meter/change";
const A = { ...DECLARATION, [NEW_ENDPOINT]: { meterNo: ["code", /^S-\d{7}$/], newNo: ["code", /^S-\d{7}$/],
  reason: ["set", ["fault", "expiry", "request"]] } };                      // known types only
const B = { ...A, [NEW_ENDPOINT]: { ...A[NEW_ENDPOINT], equipment: ["list", ["seal", "cover"]] } };  // new type

// The single central change that recognizes the type: one row in the type table.
const TYPE2 = { ...TYPE, list: (v, s) => Array.isArray(v) && v.every((x) => s.includes(x)) };
const allowlistV2 = (endpoint, record, d) => allowlist(endpoint, record, d, TYPE2);

const measure = (d, f) => {
  const P = points(d);
  const uncovered = P.filter(([e, a]) => f(e, probe(e, a, d), d) === "accept").length;
  const stopped = Object.keys(d).filter((e) => f(e, validRecord(e, d), d) !== "accept").length;
  return [P.length, uncovered, stopped];
};

console.log(`${"stage".padEnd(34)}${"points".padStart(6)}${"allow".padStart(6)}${"deny".padStart(5)}${"hand".padStart(6)}${"legit endpoints stopped".padStart(25)}`);
for (const [name, d, f] of [["current inventory", DECLARATION, allowlist],
    ["new endpoint, known types", A, allowlist], ["new field type added", B, allowlist],
    ["single type-recognizing change", B, allowlistV2]]) {
  const [n, al, st] = measure(d, f);
  console.log(name.padEnd(34) + String(n).padStart(6) + String(al).padStart(6) +
    String(measure(d, denylist)[1]).padStart(5) + String(measure(d, handWritten)[1]).padStart(6) +
    String(st).padStart(25));
}
console.log(`\nvalidation code written for the new endpoint: allowlist 0 lines (3 lines of declaration), ` +
  `hand-written ${Object.keys(B[NEW_ENDPOINT]).length} checks`);
```

```
stage                             points allow deny  hand  legit endpoints stopped
current inventory                     15     0   14    12                        0
new endpoint, known types             18     0   17    15                        0
new field type added                  19     0   18    16                        1
single type-recognizing change        19     0   18    16                        0

validation code written for the new endpoint: allowlist 0 lines (3 lines of declaration), hand-written 4 checks
```

The two middle columns rise at every stage: as the inventory grows from fifteen to
nineteen, the points the denylist leaves uncovered go from fourteen to eighteen, and the
hand-written check's go from twelve to sixteen. **Every new point is directly a candidate
opening in these two forms** — because the defense sees the point only if someone writes
it in by hand.

The allowlist column stays at zero through all three stages. When the new endpoint was
added, not a single line of validation code was written; only the declaration was
written, and validation read that declaration. Coverage stayed fixed.

The third row is this lesson's only nonzero cost cell. Adding a new **type** is a
different situation: a declaration with no counterpart in the type table is rejected, and
so the endpoint's valid record is rejected too. Coverage stays at nineteen, but one
endpoint stops. This is the error on the safe side, and it is noisy — it is seen
immediately because a legitimate request is rejected. The fourth row shows the fix: one
line in the type table, nothing else touched.

**The difference between adding a point and adding a type is permanent.** Adding a point
is a frequent, scattered task — every endpoint, every field, every release. Adding a type
is rare and happens in a single central place. The pattern that keeps coverage fixed is
the pattern that ties the frequent task to no code at all.

## Summary

- The unit of the inventory is not the endpoint but the point: five entry points carry
  fifteen field points. Each point was probed with a value that breaks its own
  declaration; none of the probes are malicious, all of them are boundary values.
- On the same fifteen probes, the allowlist closed 15 points, the denylist closed 1, and
  the hand-written check per endpoint closed 3. The number of uncovered points was 0,
  14, and 12, respectively.
- The one point the denylist closed is incidental: its general length rule happened to
  coincide with one field's upper bound. The three points the hand-written check closed
  are all from a single class, and two endpoints have no check at all.
- The routing table has 21 points, the declaration has 15; six points are uncovered by the
  inventory. The central pattern closes them only through the policy it applies to an
  undeclared endpoint: default reject closes 6 points and stops 2 endpoints, default pass
  leaves 6 points in the open and gives no sign at all.
- When the inventory grew from fifteen to nineteen, the points the allowlist left uncovered
  stayed at 0, the denylist's rose to 18, and the hand-written check's rose to 16. The
  validation code written for the new endpoint is 0 lines under the allowlist.
- Adding a new field **type** is different: a type with no counterpart in the table is
  rejected, one endpoint stops, and this is seen. The fix is a single line in the type
  table. Adding a point is frequent and touches no code; adding a type is rare and happens
  in one central place.

## Next Step

A value that passes validation conforms to the contract; it carries whatever the point's
declaration says. What happens to this value when it **leaves** the system is a separate
question. The subscriber name is written into a page body, into an attribute's quotes,
into a script variable, and into an address component; all four take the same value, and
all four are subject to a different language's rules. The next lesson counts output
points and measures a single question: how many cases break when the context is chosen
wrong, and how many output points a single encoder can close on its own.
