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

URL: https://systemdesignsimple.com/nodejs-security-common-vulnerabilities/
Published: 2026-11-06
Category: Security
Publisher: System Design Simple (https://systemdesignsimple.com)

## 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 injection:** placeholders: db.prepare('... WHERE email = ?').all(email)
- **Path traversal:** path.resolve(base, name), then require base + path.sep as prefix (for a tree attackers cannot modify)
- **Prototype pollution:** validate settings against an allow-list schema before merging; recurse only into approved plain-object fields
- **Command injection:** execFile('ls', [path]) with no shell instead of exec(string)
- **Our lab:** Node.js v22.13.1: reproduced 4/4, blocked 4/4, normal inputs unchanged 2/2
- **Dependencies:** npm 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](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html) 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](https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html). 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`](https://nodejs.org/docs/latest-v22.x/api/sqlite.html), 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.

| Mistake | Input | Unsafe result |
|---|---|---|
| SQL by template string | `x' OR '1'='1` | 3 rows returned for one email lookup |
| `path.join(base, name)` | `../secret.env` | the secret file was reachable |
| Naive deep merge of JSON | `{"__proto__": {"isAdmin": true}}` | ordinary objects read `isAdmin === true` |
| `execSync` with a shell string | `hello.txt; touch <tmp>/injected.txt` | the 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.

| Test | Input | Unsafe | Fixed |
|---|---|---|---|
| SQL lookup, rows returned (normal) | `a@example.test` | 1 | 1 |
| SQL lookup, rows returned (attack) | `x' OR '1'='1` | 3 | 0 |
| File download, file reachable (normal) | `hello.txt` | true | true |
| File download, secret reachable (attack) | `../secret.env` | true | false |
| JSON merge, objects get isAdmin | `{"__proto__": {"isAdmin": true}}` | true | false |
| ls on a user file name, extra command ran | `hello.txt; touch <tmp>/injected.txt` | true | false |

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.

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

```js
// 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](https://nodejs.org/docs/latest-v22.x/api/sqlite.html)). 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](/what-is-sql-injection-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](https://nodejs.org/docs/latest-v22.x/api/path.html)).

```js
// download.mjs (unsafe)
const file = path.join(PUBLIC_DIR, req.query.name);
```

```js
// 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`](https://nodejs.org/docs/latest-v22.x/api/fs.html#fsrealpathsyncpath-options) 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:

```js
// 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:

```js
// 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](https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html)).

## 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](https://nodejs.org/docs/latest-v22.x/api/child_process.html)). The default shell differs on Windows.

```js
// list.mjs (unsafe)
execSync(`ls ${path.join(dir, name)}`);
```

```js
// 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

[Image: 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.]

| Count | Result |
|---|---|
| Reproduced on the unsafe version | 4/4 |
| Blocked by the fix | 4/4 |
| Normal inputs unchanged | 2/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

| Risk | Fix | Costs you |
|---|---|---|
| SQL injection | placeholders for every value; allow-list table and column names | nothing at runtime; dynamic column names need a fixed list |
| Path traversal | `path.resolve` + prefix check with `path.sep`; `realpath` for a stable tree with links | does not cover a tree attackers can change |
| Prototype pollution | allow-list schema check, then merge; `Map` or `Object.create(null)` for user keys | every new setting must be added to the schema |
| Command injection | `execFile` or `spawn` with an array, no `shell: true`, `--` before user values | no pipes or globs; handle nonzero exits |
| Dependencies | `npm audit` in CI ([npm docs](https://docs.npmjs.com/cli/v10/commands/npm-audit)) | finds known package issues only |

## Test your own app

Search your own code for the four shapes:

```sh
# 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:

```js
// 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`](https://github.com/eslint-community/eslint-plugin-security/blob/main/rules/detect-child-process.js) 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](/php-security-common-vulnerabilities/), and package installs in [how apt proves a package is genuine](/apt-package-signature-flaw-check-and-fix/).

## Further reading

- [OWASP Node.js Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html): the wider list, including the permission model and dependency hygiene.
- [OWASP Prototype Pollution Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html): safe object patterns beyond merges.
- [node:sqlite](https://nodejs.org/docs/latest-v22.x/api/sqlite.html): prepared statements and binding in the runtime we tested.
- [path](https://nodejs.org/docs/latest-v22.x/api/path.html) and [child_process](https://nodejs.org/docs/latest-v22.x/api/child_process.html): exact `join`, `resolve`, `exec` and `execFile` behavior.
- [npm audit](https://docs.npmjs.com/cli/v10/commands/npm-audit): what the dependency report covers.

## 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

- [OWASP Node.js Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html)
- [OWASP Prototype Pollution Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html)
- [Node.js v22 docs: node:sqlite](https://nodejs.org/docs/latest-v22.x/api/sqlite.html)
- [Node.js v22 docs: path](https://nodejs.org/docs/latest-v22.x/api/path.html)
- [Node.js v22 docs: child_process](https://nodejs.org/docs/latest-v22.x/api/child_process.html)
- [eslint-plugin-security: detect-child-process rule](https://github.com/eslint-community/eslint-plugin-security/blob/main/rules/detect-child-process.js)
- [npm docs: npm audit](https://docs.npmjs.com/cli/v10/commands/npm-audit)
