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__.
- 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.
- 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.
- When applying a patch, allowlist the fields that may be set; reject the keys __proto__, constructor and prototype, or build onto Object.create(null).
- Never resolve a class from global, require() or a shared model registry using an attacker-supplied name.
- 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.
- Sign or MAC any state you round-trip through the client, and return a generic 400/500 so deserialization errors do not leak.
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
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
// 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
// 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
// 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 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.
// 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');
});
});- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop | Flagged 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) |
| CodeQL | js/prototype-polluting-assignment | Expected 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 |
| CodeQL | js/unsafe-deserialization | Not 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 |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| node:fs, node:test (built in) | Node.js 26.11.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/node-expressfolder.
npm ci# 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 --silentEach 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.
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
- OWASP Deserialization Cheat Sheet
- OWASP Prototype Pollution Prevention Cheat Sheet
- Node.js: JSON.parse and the __proto__ own property (V8)
- js-yaml README: load() is safe by default; JS types live in js-yaml-js-types
- Snyk: node-serialize unserialize remote code execution (CVE-2017-5941)
- CWE-502: Deserialization of Untrusted Data
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
- CAPEC-586: Object Injection
Attack patterns: