Injection

SQL, shell commands and prototype pollution: three ways client data stops being data and starts being code.

Syntaxconst { rows } = await pool.query('SELECT id FROM users WHERE email = $1', [email]);

Injection is what happens when a value you received gets treated as instructions. Build a SQL statement by pasting a string into it and the string can add clauses. Build a shell command the same way and it can add commands. In Node there is a third form that does not exist in most other runtimes: prototype pollution.

SQL: placeholders, not interpolation

Send the statement and the values separately. A placeholder, $1 in the pg driver or ? in mysql2, marks where a value goes; the driver passes the value outside the statement text, so quotes and comment markers inside it stay ordinary characters. Never template a value into SQL, not even a number.

Commands: skip the shell

child_process.exec(command) spawns a shell, /bin/sh on Unix, and the shell interprets ;, | and backticks. The Node documentation is blunt about it: never pass unsanitised user input to exec. Use execFile(file[, args]) or spawn(command[, args]) instead; both run the executable directly, with shell defaulting to false, so an argument stays one argument however it is punctuated.

Prototype pollution

Every JavaScript object inherits from a prototype object. JSON.parse keeps "__proto__" as an ordinary own key, so a recursive merge that copies keys from parsed JSON can write through to Object.prototype. One request then changes what every object in the process reports, which is how user.isAdmin becomes true for everyone. Refuse the keys __proto__, constructor and prototype, or copy out only the fields you expect.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Write a search endpoint that takes a free-text query and a sort column without building SQL by hand.
  • Call an image or PDF tool on an uploaded file without the filename becoming a shell command.
  • Harden a settings endpoint that merges a JSON patch into a stored object.
  • Explain a dependency advisory that says prototype pollution and work out whether your code is exposed.

More examples

Parameterised queries, and the one case they do not cover

The first three blocks are the same query written unsafely and then twice safely, so you can recognise both driver styles in someone else's code. The last block is the case people get wrong after they have learned placeholders: a sort column has to come from a lookup table, because no driver will parameterise an identifier.

Example · javascript
// WRONG: the value becomes part of the statement text.
const bad = `SELECT id, email FROM users WHERE email = '${email}'`;
// email = "' OR 1=1 --" turns that into: match every row, ignore the rest.

// pg: numbered placeholders. The values array travels separately.
const { rows } = await pool.query(
  'SELECT id, email FROM users WHERE email = $1 AND active = $2',
  [email, true]
);

// mysql2: the same idea with ? placeholders, in order.
const [found] = await conn.execute(
  'SELECT id, email FROM users WHERE email = ? AND active = ?',
  [email, 1]
);

// Identifiers and keywords cannot be placeholders, so map the client's
// choice onto strings you control. A plain object also inherits keys such
// as 'constructor' and 'toString', so a lookup on an unknown key can hand
// back an inherited function rather than undefined - which ?? then keeps.
// Check for an own key, so only your two strings can reach the SQL.
const SORTABLE = { created: 'created_at', name: 'display_name' };
const sort = String(req.query.sort ?? '');                  // ?? is shorthand
const column = Object.hasOwn(SORTABLE, sort) ? SORTABLE[sort] : 'created_at';
const direction = req.query.dir === 'asc' ? 'ASC' : 'DESC'; // for a default
const page = await pool.query(
  `SELECT id FROM users ORDER BY ${column} ${direction} LIMIT $1`,
  [20]
);

execFile instead of exec

Any feature that shells out - thumbnailing, PDF conversion, a git call - is a command injection waiting for a filename with a semicolon in it. execFile and spawn remove the shell entirely, which removes the whole class of problem. The pattern check is a second layer for the program's own option parsing.

Example · javascript
const { exec, execFile } = require('node:child_process');

// exec() runs the string through /bin/sh. A filename of
// 'cat.png; rm -rf /srv' is then two commands, and the second one runs.
exec(`identify ${filename}`, (err, stdout) => { /* never do this */ });

// execFile() spawns the executable directly. shell defaults to false, so
// the filename stays a single argument no matter what is in it.
execFile('identify', ['-format', '%wx%h', filename], (err, stdout) => {
  if (err) return done(err);
  console.log(stdout.trim());           // e.g. 800x600
});

// Still validate the value itself. An argument beginning with '-' can be
// read as an option by the program you are calling.
if (!/^[A-Za-z0-9._-]+$/.test(filename) || filename.startsWith('-')) {
  throw new Error('rejected filename');
}

Three ways to keep a prototype clean

Reach for the reviver when untrusted JSON flows into code you did not write, such as a config merge or an object spread deep in a library. Use Object.create(null) or a Map for anything keyed by client-supplied strings - a cache, a counter, a lookup of permissions.

Example · javascript
// 1. Drop the dangerous keys while parsing. A reviver that returns
//    undefined deletes the property, so it never exists on the result.
const body = JSON.parse(text, (key, value) =>
  key === '__proto__' || key === 'constructor' ? undefined : value
);

// 2. Build the object yourself, on no prototype at all. Object.create(null)
//    has no inherited keys, so a '__proto__' key on it is just data.
const patch = Object.create(null);
for (const key of ['name', 'email', 'locale']) {
  if (Object.hasOwn(body, key)) patch[key] = body[key];
}

// 3. A Map has no prototype keys either, and accepts any string as a key.
const counts = new Map();
counts.set(userSuppliedKey, (counts.get(userSuppliedKey) ?? 0) + 1);

// Belt and braces: starting the process with --disable-proto=throw
// (added v13.12.0) makes any access to Object.prototype.__proto__ raise
// ERR_PROTO_ACCESS, turning a silent pollution into a crash you can see.
// node --disable-proto=throw server.js

Discussion

  • Be the first to comment on this lesson.