Lesson 01 / 17
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.
Published Updated
In this lesson
- 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
Contents
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.
// 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.
// 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.
// 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.
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.