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.
"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
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
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.
// 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
// 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");
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.