Index · How it works

6 min read
20 min read
Rapid overview

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 < > & " '&lt; &gt; &amp; &quot; &#39;.
  • 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 via data- attributes or a JSON endpoint.
  • URL context — use encodeURIComponent; and validate the scheme to block javascript: URLs in href.

❌ 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:

  1. Parameterize / use safe APIs — the primary defense. Bound parameters for SQL, array-args for shells, parameterized LDAP filters, JSX/auto-escaping templates for HTML.
  2. 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.
  3. 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.
  4. 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.
  5. Defense-in-depth — CSP for XSS, WAF as a speed-bump (not a fix), parameterized everything, and monitoring/alerting on anomalies.

See also