Index · How it works
6 min read- How it works
- SQL injection — the canonical case
- Parameterized queries and prepared statements
- ORM safety — and where ORMs still let raw SQL through
- Cross-Site Scripting (XSS) — stored, reflected, DOM-based
- Output encoding by context
- Content Security Policy (CSP)
- Command (OS) injection
- LDAP and NoSQL injection
- Server-Side Template Injection (SSTI)
- The general defense pattern
How it works
Every injection vulnerability follows one template: a trusted instruction (a SQL query, an HTML page, a shell command, a template) is assembled by mixing in untrusted data, and then the whole string is given to an interpreter that parses it. Because the interpreter parses the combined string, attacker-controlled bytes that happen to be syntactically meaningful — a quote, a semicolon, a <script> tag, a backtick, a {{ }} delimiter — escape the data slot and become instructions. Defense means never letting untrusted bytes reach the interpreter as part of the instruction stream.
SQL injection — the canonical case
SQL injection occurs when user input is concatenated directly into a query string. The classic example is a login form where the input ' OR '1'='1 turns a WHERE clause into a tautology, or where '; DROP TABLE users; -- appends a second statement.
❌ Bad example
// Untrusted `email` is concatenated straight into the SQL text.
const sql = "SELECT * FROM users WHERE email = '" + email + "'";
db.query(sql);
// Input: ' OR '1'='1' --
// Result: SELECT * FROM users WHERE email = '' OR '1'='1' --'
✅ Good example
// The query text is fixed; `email` travels as a bound parameter.
const sql = "SELECT * FROM users WHERE email = $1";
db.query(sql, [email]);
Parameterized queries and prepared statements
A parameterized query (a.k.a. prepared statement) sends the query template to the database first. The database parses and plans it while the ?/$1/:email placeholders are still empty slots. The values are then transmitted on a separate channel and bound into those slots as pure data — they are never re-parsed as SQL. This is why ' OR '1'='1 supplied as a parameter is treated as a literal string to compare against, not as syntax. Prepared statements also bring a performance bonus (plan caching) and are the single most reliable defense against SQLi.
ORM safety — and where ORMs still let raw SQL through
Object-relational mappers (Entity Framework, Hibernate, Sequelize, Django ORM, ActiveRecord) parameterize by default: db.users.where(email: input) compiles to a bound query. But ORMs all provide escape hatches, and those escape hatches are where injection sneaks back in:
❌ Bad example
// Sequelize raw query with string interpolation — bypasses the ORM's safety.
sequelize.query(`SELECT * FROM users WHERE name = '${name}'`);
✅ Good example
// Raw query, but with replacements bound as parameters.
sequelize.query("SELECT * FROM users WHERE name = :name", {
replacements: { name },
});
Watch out specifically for: query()/exec() raw-SQL methods, .raw() fragments, dynamically built ORDER BY/column names (which cannot be parameterized — bind values, allow-list identifiers), and LIKE patterns where %/_ need escaping. The rule: parameters protect values; you must validate identifiers (table/column/sort) against an allow-list yourself.
Cross-Site Scripting (XSS) — stored, reflected, DOM-based
XSS is injection into HTML: attacker-controlled text is rendered into a page such that the browser executes it as script. There are three delivery models:
- Stored XSS — the payload is persisted server-side (a comment, profile bio, product review) and served to every visitor who views it. Highest impact because it's wormable and hits all users.
- Reflected XSS — the payload rides in on the request (a query string, form field) and is echoed straight back into the response. Requires luring the victim to click a crafted link, so impact is per-victim.
- DOM-based XSS — the vulnerability is entirely client-side: JavaScript reads attacker-controllable input from a source (
location.hash,document.referrer,window.name) and writes it to a dangerous sink (innerHTML,document.write,eval,setAttribute). The server may never see the payload at all.
❌ Bad example
// `innerHTML` parses the string as HTML → <img onerror> / <script> executes.
commentEl.innerHTML = userComment;
✅ Good example
// `textContent` treats the string as inert text — no HTML parsing, no script.
commentEl.textContent = userComment;
Output encoding by context
There is no single "escape" function — encoding must match the context where the data lands, because each context has different metacharacters:
- HTML body — encode
< > & " '→< > & " '. - HTML attribute — same, but unquoted attributes are dangerous; always quote and encode. A value like
" onmouseover=alert(1)breaks out of an unquoted attribute. - JavaScript context (inside a
<script>block or event handler) — HTML-encoding is not enough; you need JS string escaping (\xHH/\uHHHH). Better: don't inject data into JS at all — pass it viadata-attributes or a JSON endpoint. - URL context — use
encodeURIComponent; and validate the scheme to blockjavascript:URLs inhref.
❌ Bad example
// Data dropped into an unquoted attribute → attribute-injection XSS.
el.innerHTML = '<a href=' + url + '>link</a>';
✅ Good example
// Build nodes via the DOM API; the URL is validated and set as a property.
const a = document.createElement("a");
const u = new URL(url, location.origin);
if (u.protocol === "https:" || u.protocol === "http:") a.href = u.href;
a.textContent = "link";
el.appendChild(a);
Practically: prefer auto-escaping templates (React JSX {value}, Angular interpolation, Razor, Jinja2 autoescape). Reserve dangerouslySetInnerHTML / v-html / |safe for content you have sanitized with a vetted library (DOMPurify) and treat every use as a code-review red flag.
Content Security Policy (CSP)
CSP is a defense-in-depth HTTP header (Content-Security-Policy) that constrains where scripts, styles, images, and other resources may load from and whether inline script may run. A strong policy — e.g. script-src 'self' 'nonce-<random>'; object-src 'none'; base-uri 'none' — means that even if an attacker injects markup, an inline <script> without the correct nonce won't execute, and exfiltration to an attacker domain is blocked. CSP does not replace output encoding; it's the second wall that turns a missed escape from "account takeover" into "blocked, report-only telemetry". Avoid 'unsafe-inline' and 'unsafe-eval', which gut the policy. Prefer nonces or hashes, and add report-uri/report-to to monitor violations.
Command (OS) injection
Command injection happens when user input is incorporated into a string passed to a shell (/bin/sh -c, cmd.exe). Shell metacharacters — ; | & $() \ && ||` — let the attacker chain extra commands.
❌ Bad example
// User-controlled `host` is concatenated into a shell string.
exec("ping -c 1 " + host); // host = "8.8.8.8; rm -rf /"
✅ Good example
// No shell: the binary and args are passed as an array — metacharacters are inert.
execFile("ping", ["-c", "1", host]);
The strongest fix is to not invoke a shell at all — use the array/execFile/ProcessBuilder form so arguments are passed directly to the program. If you truly must use a shell, allow-list the input and avoid interpolation. Never rely on blacklisting metacharacters.
LDAP and NoSQL injection
The same flaw appears in non-SQL data stores:
- LDAP injection — input concatenated into an LDAP filter;
)(uid=))(|(uid=*can subvert an authentication filter. Fix: escape per RFC 4515 and use parameterized filter builders. - NoSQL injection — in MongoDB and similar, user input deserialized into a query object can smuggle operators. A JSON body
{ "password": { "$ne": null } }turns an equality check into "password is not null", bypassing auth. Server-side JavaScript ($where,mapReduce) is even worse. Fix: enforce expected types (a password must be a string, never an object), cast inputs, disable server-side JS, and use an ODM with strict schemas.
❌ Bad example
// Attacker sends { "$ne": null } as the password value.
db.users.findOne({ user: req.body.user, password: req.body.password });
✅ Good example
// Coerce to strings so operators can't be injected.
db.users.findOne({ user: String(req.body.user), password: String(req.body.password) });
Server-Side Template Injection (SSTI)
SSTI occurs when user input is rendered as part of the template itself rather than passed in as a value — e.g. render_template_string("Hello " + name) in Jinja2. Because template engines (Jinja2, Freemarker, Velocity, Twig, Handlebars, ERB) can evaluate expressions and reach runtime objects, SSTI frequently escalates to remote code execution — e.g. {{7*7}} returning 49 is the tell-tale probe, and {{ ''.__class__.__mro__[1].__subclasses__() }} walks Python internals toward os.system. Fix: never concatenate user input into a template string; pass it as a context variable to a static, pre-defined template; if you must accept templates from users, use a sandboxed/logic-less engine (e.g. a restricted Mustache) and disable dangerous globals.
The general defense pattern
Regardless of interpreter, layer these controls:
- Parameterize / use safe APIs — the primary defense. Bound parameters for SQL, array-args for shells, parameterized LDAP filters, JSX/auto-escaping templates for HTML.
- Validate with allow-lists — accept only known-good shapes (enums, regex for known formats, type coercion). Use this for what can't be parameterized: column names, sort directions, file paths, redirect targets.
- Encode on output, per context — escape data for the exact sink (HTML/attr/JS/URL). Encoding is a rendering-time concern, separate from input validation.
- Least privilege — the app's DB account should not own DDL rights or read tables it doesn't need; the OS process should run unprivileged. This caps the blast radius when a defense slips.
- Defense-in-depth — CSP for XSS, WAF as a speed-bump (not a fix), parameterized everything, and monitoring/alerting on anomalies.