Lesson 02 / 16
Injection-Class Vulnerabilities
Measuring query, command, and template injection from a single root: how many unexpected results ten legitimate search inputs produce in a concatenated query, how many of six report labels break when passed as a single string, how many of five note texts change when written into a template body, and how many of twelve call sites use concatenation.
Contents
The previous lesson established the shape of the boundary: whether the body’s fields, types, and ranges conform to the contract. A value that passes the contract is now correctly shaped, but it is still text, and it will be written somewhere inside the system. This lesson measures that moment of writing.
Three separate classes are counted — query, command, and template — and all three come down to the same decision: was the value concatenated into that context’s text, or passed as a separate operand? No malicious input is used anywhere in the measurement. Every input is text that really appears in the library catalog: author names with an apostrophe, staff notes with curly braces, report labels with spaces. What is measured is how many legitimate inputs produce an unexpected result.
AS4 (assumption): the two setups are expected to behave the same. The separate-operand path is taken as the yardstick; every result the concatenated path gives that differs from it — an error as well as a different row — counts as an unexpected result.
Query Context
The catalog search endpoint is the most frequently called place in the loan system. The same search is written with two setups and asked with ten legitimate inputs, one at a time.
// data.mjs — the loan catalog and legitimate search inputs that really appear in real catalogs import { DatabaseSync } from "node:sqlite"; export const db = new DatabaseSync(":memory:"); db.exec(`CREATE TABLE book (isbn TEXT PRIMARY KEY, title TEXT, author TEXT, year INTEGER)`); const insert = db.prepare(`INSERT INTO book VALUES (?, ?, ?, ?)`); for (const s of [ ["9789750718533", "Lost Time", "M. Proust", 1913], ["9786050000000", "History of Rock 'n' Roll", "J. O'Neill", 1998], ["9789750000001", "100% Cotton", "A. Saenz", 2004], ["9789750000002", "L'Etranger", "A. Camus", 1942], ["9789750000003", "Snow_ Pattern", "H. Yilmaz", 2011], ["9789750000004", "History -- 20th Century", "S. Aydin", 2019], ["9789750000005", 'Essay "Two"', "N. Reed", 2020], ["9789750000006", "Pencil and Paper", "B. Demir", 1995], ]) insert.run(...s); // None of these are an attack; all are legitimate search strings that appear in the library catalog. export const SEARCHES = ["Pencil", "O'Neill", "100%", "Snow_", "--", 'Two"', "Rock 'n' Roll", "Camus", "L'Etranger", "Reed's"]; // Sort field: the contract expects a field name. export const SORT = ["title", "year", "author", "year desc", "book.internal_note"];
// query.mjs — the same search, two setups: separate operand and string concatenation import { db, SEARCHES, SORT } from "./data.mjs"; const attempt = (f) => { try { return { ok: 1, v: f() }; } catch (e) { return { ok: 0, v: e.message.slice(0, 34) }; } }; const operand = (q) => db.prepare(`SELECT isbn FROM book WHERE title LIKE ? OR author LIKE ?`) .all(`%${q}%`, `%${q}%`).map((r) => r.isbn); const concatenated = (q) => db.prepare( `SELECT isbn FROM book WHERE title LIKE '%${q}%' OR author LIKE '%${q}%'`).all().map((r) => r.isbn); console.log(`${SEARCHES.length} legitimate search inputs, two setups\n`); console.log(`${"input".padEnd(16)}${"operand".padStart(9)}${"concatenated".padStart(14)} diff`); let diverged = 0, errored = 0; for (const q of SEARCHES) { const a = attempt(() => operand(q)), b = attempt(() => concatenated(q)); const same = a.ok && b.ok && String(a.v) === String(b.v); if (!same) diverged++; if (!b.ok) errored++; console.log(JSON.stringify(q).padEnd(16) + (a.ok ? String(a.v.length) : "error").padStart(9) + (b.ok ? String(b.v.length) : "error").padStart(14) + " " + (same ? "-" : b.ok ? "different row" : b.v)); } console.log(`\nunexpected result in the concatenated setup: ${diverged}/${SEARCHES.length} (${errored} of them errors)`); // A field name cannot be passed as an operand; concatenation is unavoidable here. const ALLOWED = { title: "title", year: "year", author: "author" }; const sortBy = (field, allowlisted) => { if (allowlisted && !ALLOWED[field]) return "reject"; const s = allowlisted ? ALLOWED[field] : field; return attempt(() => db.prepare(`SELECT isbn FROM book ORDER BY ${s}`).all()[0].isbn); }; console.log(`\n${"sort input".padEnd(20)}${"concatenation".padStart(18)}${"allowlist".padStart(18)}`); for (const field of SORT) { const b = sortBy(field, false), i = sortBy(field, true); const format = (r) => (r === "reject" ? "reject" : r.ok ? r.v : "error"); console.log(field.padEnd(20) + format(b).padStart(18) + format(i).padStart(18)); }
10 legitimate search inputs, two setups input operand concatenated diff "Pencil" 1 1 - "O'Neill" 1 error near "Neill": syntax error "100%" 1 1 - "Snow_" 1 1 - "--" 1 1 - "Two\"" 1 1 - "Rock 'n' Roll" 1 error near "n": syntax error "Camus" 1 1 - "L'Etranger" 1 error near "Etranger": syntax error "Reed's" 0 error near "s": syntax error unexpected result in the concatenated setup: 4/10 (4 of them errors) sort input concatenation allowlist title 9789750000001 9789750000001 year 9789750718533 9789750718533 author 9789750000002 9789750000002 year desc 9789750000005 reject book.internal_note error reject
Four inputs deviate, and what all four have in common is that they carry an apostrophe. These four readers’ searches do not work — the loan system cannot search for an author’s name. This is the most common production effect of an injection class: not a breach, a broken function.
The second table is the actual point. The sort field cannot be passed as an operand, because it is
itself part of the query text; concatenation is unavoidable there. The year desc input is
accepted without an error by concatenation and returns a different row. book.internal_note
errors. The allowlist rejects both. The one place where concatenation is mandatory is the one
place where the deviation is silent — and the only remedy there is the previous lesson’s
allowlist.
Command Context
The report service hands the export job off to a separate process. The label can be passed two ways: as a separate argument, or inside a string concatenated into the command line.
// echo.mjs — report process: prints the arguments it received as-is console.log(JSON.stringify(process.argv.slice(2)));
// command.mjs — a report label is passed to the child process two ways: separate argument and single string import { execFileSync, execSync } from "node:child_process"; // All of these are legitimate report labels written by library staff. const LABELS = ["2026-03", "daily report", "section $shelf", "O'Neill list", "second hand", "lost-found"]; const read = (f) => { try { return JSON.parse(f()); } catch { return null; } }; const operand = (e) => read(() => execFileSync("node", ["echo.mjs", e], { encoding: "utf8" })); const concatenated = (e) => read(() => execSync(`node echo.mjs ${e}`, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] })); console.log(`${LABELS.length} legitimate report labels, two passing methods\n`); console.log(`${"label".padEnd(19)}${"separate argument".padStart(22)}${"single string".padStart(26)}`); let broken = 0; for (const e of LABELS) { const a = operand(e), b = concatenated(e); const same = b && b.length === 1 && b[0] === e; if (!same) broken++; console.log(e.padEnd(19) + JSON.stringify(a).padStart(22) + (b ? JSON.stringify(b) : "process error").padStart(26)); } console.log(`\nseparate argument: 0/${LABELS.length} broken, single string: ${broken}/${LABELS.length} broken`);
6 legitimate report labels, two passing methods label separate argument single string 2026-03 ["2026-03"] ["2026-03"] daily report ["daily report"] ["daily","report"] section $shelf ["section $shelf"] ["section"] O'Neill list ["O'Neill list"] process error second hand ["second hand"] ["second","hand"] lost-found ["lost-found"] ["lost-found"] separate argument: 0/6 broken, single string: 4/6 broken
Same root, different language. The label with a space splits into two arguments, the double space
collapses to one, and the label with an apostrophe never starts the process at all. The third row
is this section’s silent case: the section $shelf label runs without an error and arrives at the
child process as section. The report is generated, the file is written, no one notices
anything; only half the label is missing.
Template Context
Notification text is produced from a template. The note a staff member writes is either given to the template as data, or written into the template’s body and then processed.
// template.mjs — the notification text is built two ways: data as an operand, data into the template body const TEMPLATE = "Dear {name}, {isbn} is due {due}. Note: {note}"; const FILL = (text, data) => text.replace(/\{(\w+)\}/g, (_, k) => data[k] ?? ""); // All legitimate note text written by staff by hand. const NOTES = ["cover worn", "return date written as {due}", "shelf {location} not found", "$5 fee", "registered under {name}"]; const BASE = { name: "A. Reed", isbn: "9789750718533", due: "2026-03-25" }; // Separate operand: the template is fixed, the data is substituted once and never scanned again. const operand = (note) => FILL(TEMPLATE, { ...BASE, note }); // Concatenation: the data is written into the template body first, then the body is processed. const concatenated = (note) => FILL(TEMPLATE.replace("{note}", note), { ...BASE, note }); const noteField = (m) => m.split("Note: ")[1]; console.log(`${NOTES.length} legitimate note texts, two setups\n`); console.log(`${"note entered".padEnd(36)}${"note printed by the concatenated setup".padEnd(40)}diff`); let broken = 0; for (const note of NOTES) { const b = concatenated(note), same = b === operand(note); if (!same) broken++; console.log(note.padEnd(36) + noteField(b).padEnd(40) + (same ? "-" : "changed")); } console.log(`\nnotification whose note field is not printed as entered: concatenated ${broken}/${NOTES.length}, operand 0/${NOTES.length}`);
5 legitimate note texts, two setups
note entered note printed by the concatenated setup diff
cover worn cover worn -
return date written as {due} return date written as 2026-03-25 changed
shelf {location} not found shelf not found changed
$5 fee $5 fee -
registered under {name} registered under A. Reed changed
notification whose note field is not printed as entered: concatenated 3/5, operand 0/5
Three of the five notes turned into a different text in the notification the reader receives. None of them errored. One of them went empty because it carried an unknown key; the record still exists, but it is not in the notification. On the separate-operand path the template is fixed and the data is substituted once and never scanned again — so all five come out exactly as entered.
Counting the Concatenation
The decision is not made in one place; it is made separately at every call site. The split system’s call sites are counted.
// calls.mjs — a count of the call sites in the split system and the measure of the one that was missed import { db, SEARCHES } from "./data.mjs"; // [service, call site, context, setup] const CALLS = [ ["catalog", "search", "query", "operand"], ["catalog", "sort", "query", "concatenation + allowlist"], ["catalog", "getByIsbn", "query", "operand"], ["loan", "issue", "query", "operand"], ["loan", "overdue", "query", "operand"], ["loan", "searchArchive", "query", "concatenation"], // the one call site that was missed ["member", "addNote", "query", "operand"], ["member", "history", "query", "operand"], ["notification", "format", "template", "operand"], ["notification", "bulkSend", "template", "operand"], ["report", "export", "command", "operand"], ["report", "archive", "command", "operand"], ]; const count = (k) => CALLS.filter(k).length; console.log(`${CALLS.length} call sites, ${new Set(CALLS.map((c) => c[0])).size} services`); for (const b of ["query", "command", "template"]) console.log(` ${b.padEnd(9)} ${count((c) => c[2] === b)} sites, ` + `${count((c) => c[2] === b && c[3].startsWith("concatenation"))} of them concatenation`); console.log(` unprotected concatenation: ${count((c) => c[3] === "concatenation")} ` + `(${CALLS.filter((c) => c[3] === "concatenation").map((c) => c[0] + "." + c[1]).join(", ")})`); // The missed call site is actually run: the same search inputs, the only difference is the setup. const searchArchive = (q) => db.prepare(`SELECT isbn FROM book WHERE title LIKE '%${q}%'`).all(); let diverged = 0; const deviations = []; for (const q of SEARCHES) { try { searchArchive(q); } catch { diverged++; deviations.push(q); } } console.log(`\nat the one call site that was missed, ${diverged} of ${SEARCHES.length} inputs gave an unexpected result`); console.log(`what the deviations have in common: all carry an apostrophe -> ${deviations.every((q) => q.includes("'"))}`); console.log(`a test set built with ${SEARCHES.filter((q) => !q.includes("'")).length} inputs carrying no apostrophe never sees this site`);
12 call sites, 5 services query 8 sites, 2 of them concatenation command 2 sites, 0 of them concatenation template 2 sites, 0 of them concatenation unprotected concatenation: 1 (loan.searchArchive) at the one call site that was missed, 4 of 10 inputs gave an unexpected result what the deviations have in common: all carry an apostrophe -> true a test set built with 6 inputs carrying no apostrophe never sees this site
Two of the twelve call sites use concatenation: one is the sort field, protected by an allowlist; the other was missed. The system runs, every endpoint responds, and no test is red. The last line says why: the deviation only shows up on inputs carrying an apostrophe, and a test set built with plain names never sees that site.
Where the setting sits, and how often it repeats: this decision does not live in a configuration file; it is made separately at each of the twelve call sites. For it to become changeable from a single place, the query, command, and template calls would all need to pass through a single wrapper — then the decision drops from 12 points to 3.
Summary
- All three classes come down to one decision: was the value concatenated into the context’s text, or passed as a separate operand. No malicious input was used anywhere in the measurement.
- 4 of ten legitimate search inputs produced an unexpected result in the concatenated query; all four carried an apostrophe and all four resulted in an error — not a breach, a broken search.
- The sort field cannot be passed as an operand; at this one place where concatenation is
mandatory, the deviation is silent:
year descwas accepted without an error and returned a different row. The allowlist rejected it. - 4 of six report labels broke when passed as a single string;
section $shelfran without an error and reached the child process cut in half. 3 of five notes changed when written into the template body, and one went empty. On the separate-operand path, breakage was zero across all three contexts. - 2 of twelve call sites use concatenation; one is protected by an allowlist, one was missed. The missed site deviates on 4 of 10 inputs, and a test set built with the remaining 6 never sees it at all. The decision is repeated at 12 points.
Next Step
The two lessons so far looked at the request’s body: whether the fields are correctly shaped, whether values are concatenated into text. Neither asked where the request came from. Yet requests to the loan system’s endpoints can arrive not only from its own interface but from other pages open in the browser, and some of these requests carry the reader’s session along with them. The next lesson measures the server’s one statement on this — how much it permits a request from a given origin — and counts where the setting lives, how many origins a wildcard value opens the door to, and why a wrong setting on a request carrying credentials fails silently, without an error.
To keep your progress and take notes, Log in
My notes
Log in to take notes.