Untrusted Input

Untrusted Input, Path Traversal and Injection

Every string from a request — query parameter, header, JSON field, filename — is attacker controlled, and two mistakes account for most of the damage: joining that string into a path, and joining it into a command line. path.resolve() and path.join() normalize ../ away, which is what an attacker wants, so resolve first and then confirm the result is still inside the root:

Containing a user-supplied filenameJavaScript
import { resolve, sep } from 'node:path';
const ROOT = resolve('public');
function safe(name) {
  const full = resolve(ROOT, name);
  if (full !== ROOT && !full.startsWith(ROOT + sep)) throw new Error(`escape: ${name}`);
  return full;
}
for (const raw of ['notes.txt', '../../Windows/win.ini', '..%2f..%2fsecret']) {
  let out;
  try { out = safe(decodeURIComponent(raw)); } catch (e) { out = e.message; }
  console.log(raw.padEnd(23), '->', out);
}
Output
notes.txt               -> C:\tmp\perm-demo\public\notes.txt
../../Windows/win.ini   -> escape: ../../Windows/win.ini
..%2f..%2fsecret        -> escape: ../../secret

Comparing against ROOT + sep rather than ROOT is deliberate: a plain startsWith(ROOT) would also accept public-backup. Decode percent-encoding before the check, as the third case shows. The guard ignores symlinks, so where users can create them, call fs.realpath() and check again.

exec() hands its string to a shell, so every metacharacter in it is live; execFile() takes an argv array and spawns the binary directly, with no shell:

exec versus execFile with hostile inputJavaScript
import { execSync, execFileSync } from 'node:child_process';
const filename = 'report.js & echo PWNED';
const print = 'console.log(process.argv[1])';
console.log(execSync(`node -e "${print}" ${filename}`).toString().trim());
console.log(execFileSync('node', ['-e', print, filename]).toString().trim());
Output
report.js
PWNED
report.js & echo PWNED

The shell ran the attacker's echo; execFileSync passed the same bytes through as one argument. Use execFile or spawn with an array everywhere, and if you truly need a shell, build the command from a fixed template with no interpolated user data.

The same discipline applies to data stores: a query built straight from req.body lets a client send { "password": { "$ne": null } } and match every document, which is why MongoDB casts values to the expected type first. Validate the request object at the edge with zod (https://github.com/colinhacks/zod 44,027 ) 4.6.5 (npm 2,036 i zod), which parses an unknown value into a typed one and gives you the TypeScript type for free.