---
title: 'Injection-Class Vulnerabilities'
source: 'https://academia.sh/en/courses/backend-production/injection-class-vulnerabilities'
course: 'Server Security and Going to Production'
language: en
updated: '2026-08-23T07:00:25+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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"];
```

```js
// 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.

```js
// echo.mjs — report process: prints the arguments it received as-is
console.log(JSON.stringify(process.argv.slice(2)));
```

```js
// 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.

```js
// 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.

```js
// 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 desc` was 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 $shelf` ran 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.
