Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix insecure deserialization in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Treat the token as data: JSON.parse it and rebuild only an allowlisted type - map $type to one known class, never the class the payload names. For a patch, allowlist the exact fields it may set; a path through __proto__ or constructor.prototype pollutes every object. Do not blocklist class names, and do not strip only __proto__.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1
  • Framework Express 5.3.0
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 27 KB
On this page

Steps

  1. Find every place a request value chooses a constructor or key: a $type/__proto__ field fed to a factory or registry, a reviver that rebuilds classes, a recursive merge/extend, lodash set/merge with a user path, or node-serialize/funcster unserialize.
  2. Parse with JSON.parse into plain data, then rebuild only types you allowlist: map the discriminator to one known class and reject anything else with 400.
  3. When applying a patch, allowlist the fields that may be set; reject the keys __proto__, constructor and prototype, or build onto Object.create(null).
  4. Never resolve a class from global, require() or a shared model registry using an attacker-supplied name.
  5. Add regression tests with this card's payloads (an unexpected $type, a __proto__ path and a constructor.prototype path) next to functional tests that round-trip the seed profile and preferences.
  6. Sign or MAC any state you round-trip through the client, and return a generic 400/500 so deserialization errors do not leak.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export function restoreProfile(token) {
  const data = JSON.parse(Buffer.from(token, 'base64').toString('utf8'));
  // Whatever type the payload names, build it. The registry holds data
  // classes AND classes with side effects, so $type is really an instruction.
  const Ctor = MODEL_REGISTRY[data?.$type];
  const instance = Ctor ? new Ctor(data) : data;
  return instance instanceof MODEL_REGISTRY.profile ? instance.view() : instance;
}

With IMPL=vulnerable, 3 of 8 tests fail (3 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
// Only these discriminators may be rebuilt, and each maps to one safe class.
const ALLOWED_TYPES = new Map([['profile', Profile]]);

export function restoreProfile(token) {
  const data = JSON.parse(Buffer.from(token, 'base64').toString('utf8'));
  const Ctor = ALLOWED_TYPES.get(data?.$type);
  if (!Ctor) {
    const error = new RangeError(`unsupported type: ${data?.$type}`);
    throw error;
  }
  return new Ctor(data).view();
}

With IMPL=fixed, 8 of 8 tests pass.

The fix: allowlisted field paths

Fixedimpl/fixed.jsJavaScript
// Only these exact field paths may be set; anything else (including
// __proto__, constructor and prototype) is refused.
const ALLOWED_PATHS = new Set(['locale', 'timezone', 'flags.beta', 'flags.emailDigest', 'recent']);

function setByPath(target, path, value) {
  let node = target;
  const keys = path.split('.');
  for (let i = 0; i < keys.length - 1; i++) node = node[keys[i]];
  node[keys[keys.length - 1]] = value;
}

export function loadPreferences(text) {
  const patch = JSON.parse(text);
  const prefs = structuredClone(PREFERENCE_DEFAULTS);
  for (const [path, value] of Object.entries(patch)) {
    if (!ALLOWED_PATHS.has(path)) throw new RangeError(`unsupported preference: ${path}`);
    setByPath(prefs, path, value);
  }
  return preferencesView(prefs);
}

With IMPL=fixed, 8 of 8 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
// Strip the one dangerous key everybody knows: __proto__. constructor and
// prototype are left alone, so constructor.prototype.x still gets through.
const BLOCKED_SEGMENT = '__proto__';

function setByPath(target, path, value) {
  const keys = path.split('.');
  if (keys.includes(BLOCKED_SEGMENT)) throw new RangeError('blocked path');
  let node = target;
  for (let i = 0; i < keys.length - 1; i++) {
    if (node[keys[i]] === undefined) node[keys[i]] = {};
    node = node[keys[i]];
  }
  node[keys[keys.length - 1]] = value;
}

export function loadPreferences(text) {
  const patch = JSON.parse(text);
  const prefs = structuredClone(PREFERENCE_DEFAULTS);
  for (const [path, value] of Object.entries(patch)) setByPath(prefs, path, value);
  return preferencesView(prefs);
}

With IMPL=wrong, 2 of 8 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/session.test.jsJavaScript
// Exploit: each payload's only job is to fire a harmless marker during
// deserialization - a written file, or a property planted on Object.prototype.
// The fix must leave every marker untouched.
describe(`exploit (IMPL=${implName})`, () => {
  test('an unexpected $type is not instantiated', async () => {
    const markerPath = join(mkdtempSync(join(tmpdir(), 'fixcard-')), 'marker.txt');
    // auditSink is a real registered class with a side effect; the blocklist
    // only named its sibling webhookNotifier.
    const token = Buffer.from(JSON.stringify({ $type: 'auditSink', markerPath }), 'utf8').toString('base64');
    await restore(token);
    assert.ok(!existsSync(markerPath), 'insecure deserialization: an attacker-chosen class was constructed');
  });

  test('a __proto__ path in a patch does not pollute Object.prototype', async () => {
    let polluted;
    try {
      await putPreferences(JSON.stringify({ '__proto__.pollutedA': true }));
      polluted = {}.pollutedA === true;
    } finally {
      delete Object.prototype.pollutedA;
    }
    assert.equal(polluted, false, 'prototype pollution via __proto__');
  });

  test('a constructor.prototype path in a patch does not pollute Object.prototype', async () => {
    let polluted;
    try {
      await putPreferences(JSON.stringify({ 'constructor.prototype.pollutedB': true }));
      polluted = {}.pollutedB === true;
    } finally {
      delete Object.prototype.pollutedB;
    }
    assert.equal(polluted, false, 'prototype pollution via constructor.prototype');
  });
});

Pitfalls

  • JSON.parse is safe, but JSON.parse creates a real own property named __proto__ (unlike an object literal), so a later merge or path-walk can carry it into Object.prototype.
  • Blocking only the literal key __proto__ is bypassed by constructor.prototype.x, as this card's wrong.js shows; a path walker that navigates existing links reaches Object.prototype through the constructor function.
  • A $type (or _$$ND_FUNC$$_, or __proto__) field is an instruction to the deserializer, not data. node-serialize and serialize-to-js revive functions and call them; js-yaml with the js-yaml-js-types add-on revives !!js/function.
  • structuredClone and JSON round-trips drop functions and prototypes, which is good, but a reviver or a class rehydrator you wrote by hand puts the type choice back in the payload's hands.
  • class-transformer @Type and similar decorators are safe only because the target type is fixed in code; driving the type from the request undoes that.
  • Object.freeze(Object.prototype) and a null-prototype options object are cheap defence in depth, but the real fix is to allowlist what the input may name or set.
  • Express body parsers and qs have had their own __proto__ handling; do not rely on the framework stripping it, and do not re-introduce it after parsing.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs node-express does this for you).
  2. Run semgrep --config r/javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop impl; expect findings in vulnerable.js and wrong.js, none in fixed.js.
  3. Against your own endpoint, send a token with an unexpected $type and a patch with a constructor.prototype path and confirm no marker file is written and ({}).polluted stays undefined.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix insecure deserialization in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.lang.security.audit.prototype-pollution.prototype-pollution-loopFlagged the setByPath navigation loop in impl/vulnerable.js and impl/wrong.js (bracket assignment into an object walked by attacker-controlled keys). No finding in impl/fixed.js, which rejects any path outside its allowlist before walking it.Checked: Semgrep 1.180.0 (CE)
CodeQLjs/prototype-polluting-assignmentExpected to be the relevant query for the sort region: it flags an object reached through a user-controlled property name (such as __proto__) that is then assigned to, which is what setByPath does. js/prototype-pollution (PrototypePollutingMergeCall.ql) covers calls to library deep-merge functions and js/prototype-pollution-utility (PrototypePollutingFunction.ql) recursive merge or extend functions, neither of which this card has.Checked: github/codeql main, query read 2026-10-11 (the card first cited a PrototypePollution.ql path that does not exist); not executed
CodeQLjs/unsafe-deserializationNot expected to flag the query region: the JS deserialization model targets known sinks (node-serialize, serialize-to-js, js-yaml load, funcster), not a hand-rolled $type-to-constructor resolver. The regression tests are the control for that surface, as for the SQL card's node:sqlite.Checked: github/codeql main; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
node:fs, node:test (built in)Node.js 26.11.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs deserialization/node-express runs all three modes and checks the expected results.

Download the project ZIP, 27 KB

Verified

Toolchain
  • node 26.11.1
  • express 5.3.0
Matrix
fixed: 8 of 8 tests pass; vulnerable: 3 of 8 tests fail (3 exploit tests); wrong: 2 of 8 tests fail (2 exploit tests)
Test results
IMPL=fixed 8/8 pass; IMPL=vulnerable 3 exploit tests fail (unexpected $type, __proto__ path, constructor.prototype path); IMPL=wrong 2 exploit tests fail (the sibling class the blocklist missed, and constructor.prototype past the __proto__-only filter). The __proto__ exploit passes under wrong, which is the point: blocking one key is not enough.
Scanners
Semgrep 1.180.0 from a throwaway venv (../.semgrep-venv), not a card dependency.
Audit
npm audit reported 0 vulnerabilities across 65 packages.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Deserialization Cheat Sheet
  2. OWASP Prototype Pollution Prevention Cheat Sheet
  3. Node.js: JSON.parse and the __proto__ own property (V8)
  4. js-yaml README: load() is safe by default; JS types live in js-yaml-js-types
  5. Snyk: node-serialize unserialize remote code execution (CVE-2017-5941)
  6. CWE-502: Deserialization of Untrusted Data
  7. CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
  8. CAPEC-586: Object Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.