System Design Simple logoSystem Design Simple Follow on YouTube

Security

Node.js security: 4 common vulnerabilities we reproduced and fixed

SQL by template string, path.join on user input, a deep merge that walks into __proto__, and exec with a shell string: reproduced in our lab, then fixed.

Node.js security: 4 common vulnerabilities we reproduced and fixed: diagrams from the video

Drafted with AI assistance, then checked and edited by a human engineer · How we write

Short answer

Keep request data out of SQL syntax, file paths and shell commands, and validate settings before merging them. Fixes: placeholders, path.resolve plus a prefix check, a merge that checks an allow-list first, and execFile with an argument array. In our Node.js v22.13.1 lab all 4 attack inputs worked on the unsafe code, the fix blocked all 4, and 2 of 2 normal inputs still worked.

Key facts
SQL injectionplaceholders: db.prepare('... WHERE email = ?').all(email)
Path traversalpath.resolve(base, name), then require base + path.sep as prefix (for a tree attackers cannot modify)
Prototype pollutionvalidate settings against an allow-list schema before merging; recurse only into approved plain-object fields
Command injectionexecFile('ls', [path]) with no shell instead of exec(string)
Our labNode.js v22.13.1: reproduced 4/4, blocked 4/4, normal inputs unchanged 2/2
Dependenciesnpm audit in CI; it does not find bugs in your own code

Keep request data separate from SQL syntax, filesystem boundaries and shell commands, and validate settings before merging them into objects. Our local lab reproduced four vulnerable patterns and blocked its four attack inputs after applying targeted fixes. All four are vulnerabilities in application code, so npm audit will not report them. Prototype pollution, the one specific to JavaScript, gets the longest section.

This guide is for protecting applications you own or maintain.

Background

The OWASP Node.js Security Cheat Sheet calls child_process.exec "very dangerous" because the call goes through a shell, warns that careless fs use leads to directory traversal, and recommends allow-list validation, npm audit and eslint-plugin-security. Prototype pollution has its own OWASP prevention sheet. We tested the four code patterns ourselves; the tooling advice comes from those pages.

Prerequisites and definitions

You need to know what a JavaScript object inherits and what a shell does with a string.

Definition

Prototype inheritance: when a property is missing on an object, JavaScript looks it up on the object's prototype. An object literal such as {} has Object.prototype as its prototype, so a property written there is visible on every such object that does not define its own.

Definition

Injection: user text reaches an interpreter (SQL, a shell) as code instead of as a value, because the two were joined into one string.

Definition

Path traversal: a file name containing .. or an absolute path resolves to a file outside the folder you meant to serve.

Reproduce: the four mistakes on the unsafe version

Lab nodejs-security-lab is one offline script, labs/security/nodejs-security-lab/nodejs-security-lab.mjs, with no server and no network. It creates an in-memory SQLite table of three fake users through node:sqlite, a temporary folder with public/hello.txt next to a fake secret.env, a settings merge, and a file listing done with ls. We ran it on this Mac on 2026-11-06 at 10:27 UTC with Node.js v22.13.1.

MistakeInputUnsafe result
SQL by template stringx' OR '1'='13 rows returned for one email lookup
path.join(base, name)../secret.envthe secret file was reachable
Naive deep merge of JSON{"__proto__": {"isAdmin": true}}ordinary objects read isAdmin === true
execSync with a shell stringhello.txt; touch <tmp>/injected.txtthe second command ran

Experiment: attack and normal inputs on both versions

We also sent normal inputs, to check that each fix keeps real requests working.

TestInputUnsafeFixed
SQL lookup, rows returned (normal)a@example.test11
SQL lookup, rows returned (attack)x' OR '1'='130
File download, file reachable (normal)hello.txttruetrue
File download, secret reachable (attack)../secret.envtruefalse
JSON merge, objects get isAdmin{"__proto__": {"isAdmin": true}}truefalse
ls on a user file name, extra command ranhello.txt; touch <tmp>/injected.txttruefalse

The merge payload is ordinary JSON that JSON.parse accepts, and after one merge call an object created later, in code that never saw the request, reported isAdmin as true.

Fix: SQL injection in Node.js

A template string puts user text inside the statement, so a quote in the text closes the string literal and OR '1'='1 becomes part of the condition.

// users.mjs (unsafe)
const rows = db.prepare(`SELECT * FROM users WHERE email = '${email}'`).all();
// users.mjs (fixed)
const rows = db.prepare('SELECT id, email FROM users WHERE email = ?').all(email);

With a placeholder, SQLite compiles the statement before the value exists, and all(email) binds the value into that slot (node:sqlite docs). The quotes in x' OR '1'='1 are then characters of an email that matches nobody, which is why the fixed lookup returned 0 rows. More in what SQL injection is and how to prevent it.

Is path.join safe for user file names? (path traversal)

path.join normalizes its result, and normalizing resolves .. segments. join(pub, '../secret.env') is the parent folder's secret.env, a valid path (Node path docs).

// download.mjs (unsafe)
const file = path.join(PUBLIC_DIR, req.query.name);
// download.mjs (fixed)
const file = path.resolve(PUBLIC_DIR, req.query.name);
if (!file.startsWith(PUBLIC_DIR + path.sep)) return res.status(404).end();

path.resolve returns an absolute path, and an absolute input such as /etc/hosts replaces the base entirely, so only the prefix check keeps the result inside the folder. PUBLIC_DIR must itself be absolute and resolved. The + path.sep stops a sibling folder named public-old from passing a check for public.

This example assumes attackers cannot modify the served directory tree. For a stable tree containing symlinks, canonicalize both the base and candidate with fs.realpathSync before checking containment. If attackers can replace path components between checking and opening, this check is insufficient; use an isolated file-serving design.

What is prototype pollution in a deep merge?

JSON.parse('{"__proto__":{"isAdmin":true}}') creates an object with an own property literally named __proto__; parsing changes no prototype. The damage happens in the merge the lab ran:

// merge.mjs (unsafe)
function merge(t, s) {
  for (const k in s) {
    if (typeof s[k] === 'object' && s[k]) { t[k] ??= {}; merge(t[k], s[k]); }
    else t[k] = s[k];
  }
  return t;
}

When k is "__proto__" and the target is {}, reading t[k] goes through the Object.prototype.__proto__ accessor and returns Object.prototype. It is not null, so ??= does nothing, and the recursive call writes isAdmin = true onto Object.prototype. Ordinary objects inheriting from Object.prototype then read isAdmin === true unless they shadow that property; objects made with Object.create(null) do not.

The lab's fix skips __proto__, constructor and prototype and checks Object.hasOwn before descending. It blocks this payload, but it is not a general safe merge: merge({theme: 'dark'}, {theme: {color: 'blue'}}) throws TypeError: Cannot create property 'color' on string 'dark'. For production, check the whole input against an allow-list of settings first, then recurse only into approved plain-object fields:

// settings.mjs (fixed): check the input against an allow-list, then merge
const SCHEMA = { theme: 'string', fontSize: 'number', notify: { email: 'boolean', push: 'boolean' } };
const isPlain = (v) => v !== null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype;

function check(s, schema) {
  if (!isPlain(s)) throw new TypeError('expected a plain object');
  for (const k of Object.keys(s)) {
    if (!Object.hasOwn(schema, k)) throw new TypeError(`unknown setting: ${k}`);
    if (typeof schema[k] === 'object') check(s[k], schema[k]);
    else if (typeof s[k] !== schema[k]) throw new TypeError(`${k} must be a ${schema[k]}`);
  }
}

function apply(t, s, schema) {
  for (const k of Object.keys(s)) {
    if (typeof schema[k] === 'object') {
      if (!Object.hasOwn(t, k) || !isPlain(t[k])) t[k] = {};
      apply(t[k], s[k], schema[k]);
    } else t[k] = s[k];
  }
  return t;
}

export function merge(t, s, schema = SCHEMA) {
  check(s, schema); // throws before anything is written
  return apply(t, s, schema);
}

__proto__ and constructor are rejected because they are not own keys of the schema, so there is no denylist to keep complete. Arrays and null fail isPlain, and a wrong type is rejected before any write. This schema has no array fields; if you add one, replace the array whole after checking each element. For lookups keyed by user input, Object.create(null) creates a dictionary without a prototype. A Map stores entries accessed through get and set, so entry keys such as __proto__ do not traverse object properties (OWASP prevention sheet).

exec vs execFile: command injection in child_process

On this macOS lab, execSync uses /bin/sh; shell parsing turns ; into a command separator (child_process docs). The default shell differs on Windows.

// list.mjs (unsafe)
execSync(`ls ${path.join(dir, name)}`);
// list.mjs (fixed)
try {
  execFileSync('ls', ['--', path.join(dir, name)]);
} catch (err) {
  // nonzero exit, for example a file that does not exist
}

execFile without shell: true starts ls directly with an argument array. The whole name, semicolon included, arrives as one argument, so ls looked for a file with that literal name and nothing else ran. No such file exists, so the fixed call throws; handle that expected failure. The -- stops a name starting with - from being read as an option; the lab code did not include it, so that part is our advice and was not measured.

Report: what our lab showed

Terminal output of our nodejs-security-lab lab run: node run.mjs
Our run of node labs/security/nodejs-security-lab/nodejs-security-lab.mjs on 2026-11-06, Node.js v22.13.1.
CountResult
Reproduced on the unsafe version4/4
Blocked by the fix4/4
Normal inputs unchanged2/2

The lab runs one process with in-memory SQLite, no web framework and macOS /bin/sh, and sends one attack input per mistake. The path check was not tested against symbolic links or a changing folder tree. The merge test ran the lab's denylist version with only a __proto__ payload; the allow-list settings.mjs above is our recommendation and is covered by the unit tests below, not by the run in the figure. We would prioritize reviewing recursive merges, because a write can affect objects outside the request handler.

Fix checklist and trade-offs

RiskFixCosts you
SQL injectionplaceholders for every value; allow-list table and column namesnothing at runtime; dynamic column names need a fixed list
Path traversalpath.resolve + prefix check with path.sep; realpath for a stable tree with linksdoes not cover a tree attackers can change
Prototype pollutionallow-list schema check, then merge; Map or Object.create(null) for user keysevery new setting must be added to the schema
Command injectionexecFile or spawn with an array, no shell: true, -- before user valuesno pipes or globs; handle nonzero exits
Dependenciesnpm audit in CI (npm docs)finds known package issues only

Test your own app

Search your own code for the four shapes:

# from your project root
grep -rnE "prepare\(\`|query\(\`|execute\(\`" src/              # SQL in template strings
grep -rnE "path\.join\([^)]*req\." src/                         # paths built from request data
grep -rnE "for \(const [a-zA-Z_]+ in |deepmerge|_\.merge\(" src/  # hand-written or library merges
grep -rnE "exec(Sync)?\(|spawn(Sync)?\(|shell: *true" src/      # child_process calls to review
npm audit --audit-level=high

Then pin each fix with unit tests that send attack inputs and normal ones:

// security.test.mjs (node --test)
import test from 'node:test';
import assert from 'node:assert';
import { merge } from './settings.mjs';

test('normal settings merge', () => {
  const t = { theme: 'dark', notify: { email: true } };
  merge(t, JSON.parse('{"fontSize":14,"notify":{"push":false}}'));
  assert.deepStrictEqual(t, { theme: 'dark', fontSize: 14, notify: { email: true, push: false } });
});

test('wrong shapes are rejected before any write', () => {
  const t = { theme: 'dark' };
  for (const body of ['{"theme":{"color":"blue"}}', '{"notify":null}', '{"notify":[]}', '{"fontSize":14,"theme":1}']) {
    assert.throws(() => merge(t, JSON.parse(body)), TypeError);
  }
  assert.deepStrictEqual(t, { theme: 'dark' });
});

test('prototype keys never reach Object.prototype', () => {
  for (const body of ['{"__proto__":{"isAdmin":true}}', '{"constructor":{"prototype":{"isAdmin":true}}}']) {
    assert.throws(() => merge({}, JSON.parse(body)), TypeError);
    assert.strictEqual(({}).isAdmin, undefined);
  }
});

Add eslint-plugin-security to your linter; its rules give review hints. detect-child-process checks nonliteral exec calls; separately inspect execSync, spawn and shell-enabled calls. The same check, test and fix approach covers PHP in our PHP security checklist, and package installs in how apt proves a package is genuine.

Further reading

Frequently asked questions

Is path.join safe for user file names in Node.js?

No. path.join normalizes '..' segments, so ../secret.env leaves your folder. Use path.resolve and require the result to start with the base folder plus path.sep. That check assumes attackers cannot change the folder tree; if they can swap links or folders between the check and the open, use an isolated file-serving design.

Is execFile always safe from command injection?

It avoids shell parsing as long as you do not pass shell: true. The program still reads its arguments, so put '--' before user values for tools that accept options, and handle the error execFileSync throws on a nonzero exit.

Does npm audit find these four bugs?

No. npm audit reports known vulnerable package versions. Bugs in your own code need code search, unit tests and linter hints.

Sources

  1. OWASP Node.js Security Cheat Sheetcheatsheetseries.owasp.org
  2. OWASP Prototype Pollution Prevention Cheat Sheetcheatsheetseries.owasp.org
  3. Node.js v22 docs: node:sqlitenodejs.org
  4. Node.js v22 docs: pathnodejs.org
  5. Node.js v22 docs: child_processnodejs.org
  6. eslint-plugin-security: detect-child-process rulegithub.com
  7. npm docs: npm auditdocs.npmjs.com

node.jsnodejssecurityvulnerabilitysql injectionpath traversal

System Design Simple logo
System Design Simple

Written by a software engineer and system architect with 10+ years of building production web, payment, ticketing and accounting systems in Japan and Nepal. About us