Injection

Stop mixing data into a language, in every language you use.

03of 16
≈ 13Min read
5Parts

One mistake, four languages

Injection happens whenever a value is pasted into a string that something else will parse: SQL, a shell command, a file path, an HTML template, an LDAP filter, a regular expression. The parser cannot tell your text from the attacker's, because by the time it arrives they are the same string.

The fix is always the same shape: keep the data out of the sentence. Pass it beside the command, as a value, in a channel the parser treats as data.

the same bug, four ways
"select * from users where email = '" + email + "'"     // SQL
exec("convert " + filename + " out.png")                   // shell
readFile("/uploads/" + name)                               // path
"<p>" + comment + "</p>"                                   // HTML

SQL: parameters, not strings

covered in full in the SQL course — the rule, again
db.query("select * from users where email = $1", [email]);       // postgres
db.prepare("select * from users where email = ?").get(email);    // sqlite

// a placeholder is a VALUE. it cannot be a table or a column name.
const column = ALLOWED_SORTS[req.query.sort] ?? "created_at";    // allowlist those
An ORM is not immunity

Every ORM has a raw escape hatch, and a where clause built from a search filter is a string being assembled. The bug moved; it did not leave.

Shell: do not use a shell

exec starts /bin/sh. A filename of a.png; rm -rf / is then two commands. execFile and spawn pass an array straight to the process — there is no shell to interpret anything, so there is nothing to escape.

If you genuinely need a shell feature (a pipe), build the command from constants and pass user data through the environment or stdin instead.

the argument array is the whole defence
// dangerous: one string, parsed by a shell that understands ; | && $( )
exec(`convert ${file} out.png`);

// safe: no shell, and the arguments are arguments
execFile("convert", [file, "out.png"]);
spawn("convert", [file, "out.png"], { shell: false });

Paths: resolve, then check

../ is the oldest attack on this list
// the bug
readFile("/uploads/" + name);        // name = "../../etc/passwd"

// the fix: normalise first, then prove it is still inside
const root = path.resolve("/uploads");
const full = path.resolve(root, name);

if (!full.startsWith(root + path.sep)) throw new Error("no");
Stripping "../" is not a fix

A blocklist replace turns ....// into ../ — the replacement recreates the pattern it was removing. URL-encoded (%2e%2e%2f), double-encoded and unicode variants all decode later, too. Resolving the path is immune to all of it: to resolve, .... is just an unusual directory name, and the only question left is whether the result is still inside the root.

The pattern that fits all of them

  • Use the parameterised API. Almost every library has one; if yours does not, that is a reason to change library.
  • Allowlist the things that cannot be parameters — column names, sort directions, file names, command names. A fixed map from an input to a safe value.
  • Validate for shape, not for danger. "Is this an integer / a UUID / one of these five words" is a check you can be sure of. "Does this contain anything bad" is a guess.
  • Least privilege behind it. The database user cannot drop tables; the process cannot write outside its directory. Then a missed check is a smaller incident.

Practice