GUIDE · OWASP Top 10:2025
A05:2025 Injection
Untrusted data is sent to an interpreter as part of a command or query, so data becomes code.
At a glance
- WHAT
- Untrusted data is sent to an interpreter as part of a command or query, so data becomes code.
- HAPPENS
- SQLi (string concat into SQL); NoSQLi ({ $gt: '' } from JSON body into Mongo); command injection (exec('ping ' + host)); XSS — reflected / stored / DOM (innerHTML, document.write, eval); SSTI (user string compiled as template); LDAP / XPath injection; header injection (CRLF in redirect Location / Set-Cookie); log injection; ORM injection via raw(); GraphQL injection.
- SPOT
- template literals or + building SQL/shell/HTML;
exec(;$where; req.body passed straight into find(); innerHTML / dangerouslySet InnerHTML / v-html;new Function;res.redirect(req.query.next); Handlebars.compile(userInput). - FIX
- parameterized queries / prepared statements ($1, ?); ORM query builders; schema validation (zod/joi) rejecting objects where strings expected; execFile with argv array (no shell); contextual output encoding (escapeHtml, attr, JS, URL); textContent not innerHTML; sanitizer (DOMPurify) for rich text; CSP as defense in depth; least-privilege DB user; allowlists over denylists.
- SAY
- CWE-89 SQL Injection, CWE-943 NoSQL Injection, CWE-78 OS Command Injection, CWE-79 XSS, CWE-94 Code Injection, CWE-1336 SSTI, CWE-113 HTTP Response Splitting. Pitch: "Separate code from data: parameterize, validate on input, encode on output, CSP as backstop."
The idea#
Picture a fill-in-the-blank worksheet. The printed line is the instruction: "Please find the student named ____." The blank is supposed to hold a name. If the teacher reads whatever landed in the blank as more instruction, the worksheet stops being a form. The words you wrote became part of the command.
That is Injection. Untrusted text is handed to an interpreter as if it belonged in the program. The interpreter might be a database, a shell, a browser, a template engine, or a response header. The real control is to keep the command fixed and pass the data beside it. A SQL call keeps a constant query string and sends the value as a parameter. A login filter demands real strings and rejects an object where a string was required, so a query operator never becomes the password. A process is started with an argument array, and the host is checked against a hostname pattern before it joins that array. HTML is escaped for the context it lands in. A link a user supplied is kept only when the scheme is http or https. Templates are functions you wrote, chosen by a fixed name, and the user's text is data those functions escape. A header value that contains a carriage return or a line feed is rejected.
Failure looks like the blank eating the sentence. Text built into a SQL string with concatenation changes the shape of the statement. A JSON object arrives where a password string should be, and the database treats the object's keys as operators. A host value glued into a shell command line gives the shell extra words to run. A name dropped into HTML with innerHTML, or into a string you send as markup, becomes markup. A template the user authored gets compiled on the server. A redirect value that carries a line break smuggles a second header.
Why it matters#
Injection is how a form field becomes someone else's query, process, or page. On the 2025 OWASP list this category includes cross-site scripting, because the browser is an interpreter too.
On the way in, check the type and the shape. On the way into an interpreter, bind data apart from the command. On the way out, encode for HTML, a URL, or a header. A content security policy is the backstop when a page still slips. Give the database login only the rights that feature needs. Allow a template name, a hostname pattern, or a URL scheme you listed.
A small example#
function findUserByEmail(email) { if (typeof email !== 'string' || email.length === 0 || email.length > 256) { throw new TypeError('email must be a string'); } return { text: 'SELECT id FROM users WHERE email = $1', values: [email], };} const query = findUserByEmail('student@school.example');query.text stays the sentence you wrote. email rides in values. The database binds it as data, so the characters in the string do not become syntax.
What the code below is doing#
Six interpreters. Six ways of keeping data beside the command.
SQL is buildSafeQuery, next to a string-building function the page never executes. The safe function returns a fixed text and a values array. The asserts keep the text constant and keep the input out of that text.
Document stores are requireString, loginFilter, and hasOperatorKey. Email and password have to be strings with a bounded length. An object in either field throws. hasOperatorKey walks nested objects and reports a key that starts with $.
Operating-system commands are pingArgs. The host has to match a hostname pattern. The return value is ['-c', '1', host], the shape you pass to execFile so a shell never parses the host. A host that fails the pattern throws. The comment above names the timeout you would pass with that call.
Cross-site scripting is escapeHtml and safeHref. escapeHtml replaces &, <, >, quotes, and / with entities. safeHref allows http: and https: and returns # for anything else, including schemes a browser would treat as script. A short content security policy is checked so the policy text contains no unsafe-inline grant.
Templates are the templates map and render. Lookup uses Object.hasOwn. An unknown name throws. welcome escapes name through escapeHtml. The user's characters stay data inside a template that lives in your code.
Headers are headerValue. A carriage return or a line feed throws. An ordinary path is returned as itself. The closing comment points at directory filters: escape the metacharacters, or call a library that binds the values.
When you talk about it, say this: keep the command fixed, pass data beside it, check types on the way in, and encode on the way out.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');SQL injection#
// VULNERABLE:// const sql = `SELECT * FROM users WHERE email = '${email}' AND pw = '${pw}'`;// db.query(sql); // email = "' OR 1=1 --"// FIXED: query text is constant; values travel separately as parameters.function buildVulnerableSql(email) { // shown ONLY to demonstrate; never executed return `SELECT * FROM users WHERE email = '${email}'`;}function buildSafeQuery(email) { return { text: 'SELECT * FROM users WHERE email = $1', values: [email] };}const payload = "' OR 1=1 --";const q = buildSafeQuery(payload);Verified by 4 asserts
assert.equal(buildVulnerableSql(payload), "SELECT * FROM users WHERE email = '' OR 1=1 --'"); // the WHERE is goneassert.equal(q.text, 'SELECT * FROM users WHERE email = $1'); // text unchanged by inputassert.deepEqual(q.values, ["' OR 1=1 --"]); // payload is just a string valueassert.equal(q.text.includes(payload), false);NoSQL injection#
// VULNERABLE:// const user = await User.findOne({ email: req.body.email, password: req.body.password });// body = { "email": "admin@x.com", "password": { "$gt": "" } } -> matches any password// FIXED: validate types at the boundary; reject operator keys.function requireString(v, field) { if (typeof v !== 'string') throw new TypeError(`${field} must be a string`); if (v.length === 0 || v.length > 256) throw new RangeError(`${field} length`); return v;}function loginFilter(body) { return { email: requireString(body.email, 'email'), password: requireString(body.password, 'password') };}function hasOperatorKey(obj) { if (obj === null || typeof obj !== 'object') return false; return Object.keys(obj).some((k) => k.startsWith('$') || hasOperatorKey(obj[k]));}Verified by 5 asserts
assert.deepEqual(loginFilter({ email: 'a@b.c', password: 'hunter2' }), { email: 'a@b.c', password: 'hunter2' });assert.throws(() => loginFilter({ email: 'admin@x.com', password: { $gt: '' } }), /password must be a string/);assert.throws(() => loginFilter({ email: { $ne: null }, password: 'x' }), /email must be a string/);assert.equal(hasOperatorKey({ a: { b: { $where: 'this.x' } } }), true);assert.equal(hasOperatorKey({ a: 'plain' }), false);OS command injection#
// VULNERABLE:// exec(`ping -c 1 ${req.query.host}`); // host = "8.8.8.8; rm -rf /" (shell parses it)// FIXED: execFile with an argv array — no shell, so metacharacters are inert. Validate anyway.// const { execFile } = require('node:child_process');// execFile('ping', ['-c', '1', host], { timeout: 5000 }, cb);const HOSTNAME = /^[A-Za-z0-9.-]{1,253}$/;function pingArgs(host) { if (!HOSTNAME.test(host)) throw new Error('invalid host'); return ['-c', '1', host];}Verified by 4 asserts
assert.deepEqual(pingArgs('example.com'), ['-c', '1', 'example.com']);assert.throws(() => pingArgs('8.8.8.8; rm -rf /'), /invalid host/);assert.throws(() => pingArgs('$(whoami)'), /invalid host/);assert.throws(() => pingArgs('a.com && cat /etc/passwd'), /invalid host/);XSS#
output encoding
// VULNERABLE:// res.send(`<h1>Hello ${req.query.name}</h1>`); // reflected// el.innerHTML = comment.body; // stored / DOM// location.href = params.get('next'); // DOM XSS via javascript: URL// FIXED: encode for the HTML context; textContent in the DOM; DOMPurify for rich text.const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/' };function escapeHtml(s) { return String(s).replace(/[&<>"'/]/g, (c) => HTML_ESCAPES[c]); }const xss = '<script>alert(document.cookie)</script>';// URL context: only allow http(s) for user-supplied links (blocks javascript: and data:)function safeHref(href) { try { const u = new URL(href, 'https://example.com'); return ['http:', 'https:'].includes(u.protocol) ? u.href : '#'; } catch { return '#'; }}Verified by 9 asserts
assert.equal(escapeHtml(xss), '<script>alert(document.cookie)</script>');assert.equal(escapeHtml(xss).includes('<'), false);assert.equal(escapeHtml('" onmouseover="alert(1)'), '" onmouseover="alert(1)');assert.equal(escapeHtml("'><img src=x onerror=alert(1)>"), ''><img src=x onerror=alert(1)>');assert.equal(escapeHtml('a&b'), 'a&b');assert.equal(safeHref('javascript:alert(1)'), '#');assert.equal(safeHref('data:text/html,<script>alert(1)</script>'), '#');assert.equal(safeHref('/profile'), 'https://example.com/profile');// CSP (defense in depth — stops inline script execution even if encoding is missed):const csp = "default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'; base-uri 'none'";assert.equal(csp.includes("'unsafe-inline'"), false);Server-side template injection (SSTI)#
// VULNERABLE:// const tpl = Handlebars.compile(req.body.template); // user controls the TEMPLATE, not just data// ejs.render(`Hello ${req.query.name}`); // name = "<%= process.mainModule.require('child_process')... %>"// FIXED: templates are static files from the repo; user input is only ever DATA passed to render.const templates = { welcome: (d) => `Hello ${escapeHtml(d.name)}` };function render(name, data) { if (!Object.hasOwn(templates, name)) throw new Error('unknown template'); return templates[name](data);}Verified by 2 asserts
assert.equal(render('welcome', { name: '{{constructor.constructor("return process")()}}' }), 'Hello {{constructor.constructor("return process")()}}');assert.throws(() => render('__proto__', {}), /unknown template/);Header injection (CRLF) / LDAP (mention)#
// VULNERABLE: res.setHeader('Location', req.query.next); // next = "/x\r\nSet-Cookie: admin=1"// FIXED: strip CR/LF; Node's setHeader also throws on them — keep that as a backstop.function headerValue(v) { if (/[\r\n]/.test(v)) throw new Error('CRLF in header'); return v;}// LDAP: escape ( ) * \ NUL in filter values, or use a library with parameterized filters (CWE-90).Verified by 2 asserts
assert.throws(() => headerValue('/x\r\nSet-Cookie: admin=1'), /CRLF/);assert.equal(headerValue('/dashboard'), '/dashboard');See also