CWE-502: Deserialization of Untrusted Data - JavaScript / Node.js
Overview
JavaScript deserialization vulnerabilities occur when eval(), Function(), or vulnerable libraries parse untrusted data, allowing attackers to execute arbitrary code. In Node.js the untrusted data usually arrives in a cookie, a response from an external API, or a user upload.
Primary Defence: Use JSON.parse() for safe deserialization and avoid eval(), Function(), vm.runInNewContext(), and vulnerable libraries like node-serialize.
Common Vulnerable Patterns
eval() with User Input
// VULNERABLE - eval executes arbitrary code!
const userInput = req.query.data;
const result = eval(userInput); // RCE!
// Attacker sends: ?data=require('child_process').exec('rm -rf /')
Why this is vulnerable:
- Executes attacker-supplied JavaScript as code.
- Allows
require('child_process')and command execution. - Exposes filesystem, environment, and process state.
- Bypasses all input validation or allowlists.
Function() Constructor
// VULNERABLE - Function constructor is like eval
const code = req.body.code;
const fn = new Function(code); // Code execution!
fn();
// Or with return statement:
const evaluate = new Function('return ' + userInput); // Still vulnerable!
Why this is vulnerable:
- Compiles untrusted strings into executable code.
- Same attack surface as
eval(). - Enables
require()access to OS commands. - Runs with the server's privileges.
node-serialize with Untrusted Data
// VULNERABLE - node-serialize can execute code
const serialize = require('node-serialize');
const userData = req.cookies.user;
const user = serialize.unserialize(userData); // RCE!
// Attacker can craft:
// {"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('malicious')}()"}
Why this is vulnerable:
- Supports function serialization markers (
_$$ND_FUNC$$_). - Reconstructs and executes functions during deserialization.
- Attackers can inject IIFEs that run immediately.
- No safe mode for untrusted input.
vm.runInNewContext Without Sandboxing
// VULNERABLE - vm module doesn't provide security
const vm = require('vm');
const code = req.body.script;
vm.runInNewContext(code); // Can escape sandbox!
// Attacker can access process and execute commands
Why this is vulnerable:
vmis not a security sandbox.- Constructor chains can escape the context.
- Access to
processenables command execution. - Runs attacker code inside the same process.
Related Risk: Prototype Pollution via Unsafe Merge
This is a different CWE and has its own page: file it as
CWE-1321, which carries the null-prototype targets,
the schema-parsing pattern and the runtime hardening options. It is repeated
here because it is what a JavaScript codebase is usually left holding after the
eval-based deserialization on this page has been fixed.
// VULNERABLE - hand-rolled recursive merge writes through __proto__
function merge(target, source) {
for (const key in source) {
if (source[key] && typeof source[key] === 'object') {
target[key] = target[key] || {};
merge(target[key], source[key]); // descends into __proto__
} else {
target[key] = source[key];
}
}
return target;
}
const data = JSON.parse(req.body);
merge(userProfile, data);
// Attacker sends: {"__proto__": {"isAdmin": true}}
// Now ALL objects appear to have isAdmin: true
if (someOtherUser.isAdmin) { // true for every object in the process!
grantAdminAccess();
}
Why this is vulnerable: JSON.parse is not the sink. It creates
__proto__ as an ordinary own data property, so the parsed object is inert -
measured on Node 24, Object.prototype.isAdmin is still undefined
immediately after the parse. The damage happens at the merge, and only a
recursive one reaches Object.prototype: the walk reads
target['__proto__'], which resolves through the accessor to
Object.prototype, and then writes isAdmin onto it. Every object in the
process inherits it, so an authorization check on an object the attacker never
touched now passes. A path-setting helper - set(obj, '__proto__.isAdmin',
true) - has the same shape for the same reason.
Two neighbouring cases are worth telling apart, because both read as this bug and neither behaves like it:
Object.assign(userProfile, data)assigns through the__proto__setter, which replaces that one object's prototype.userProfile.isAdminbecomestrueand no other object changes. It cannot reachObject.prototypebecause it does not recurse.- A maintained utility library is not the example here. Measured on lodash
4.18.1,
_.merge,_.defaultsDeepand_.setall leaveObject.prototypeuntouched - the library skips__proto__keys internally, and has since the 4.17.x fixes. The live population of this bug is application code like the function above, so look for the merge you wrote before you look for the one you imported.
Secure Patterns
JSON.parse (Safe for Data Only)
// SECURE - JSON.parse only creates data structures
const express = require('express');
const app = express();
app.use(express.json()); // Built-in JSON parser
app.post('/users', (req, res) => {
// req.body is already parsed JSON (safe)
const { name, email, age } = req.body;
// Manually construct object
const user = {
name: String(name),
email: String(email),
age: Number(age)
};
// Validate
if (!isValidEmail(user.email)) {
return res.status(400).json({ error: 'Invalid email' });
}
// Save user...
res.json(user);
});
// For manual JSON parsing:
try {
const data = JSON.parse(untrustedString);
// data is plain object, no code execution
} catch (err) {
console.error('Invalid JSON');
}
Why this works:
- Parses data without executing code.
- Produces only objects, arrays, and primitives.
- No class instantiation, and no access to constructors or functions.
- Safe when combined with validation.
Prevent Prototype Pollution
// SECURE - Block prototype pollution keys on parse
function safeParse(jsonString) {
return JSON.parse(jsonString, (key, value) => {
// Block prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return undefined;
}
return value;
});
}
// Use Object.create(null) for dictionaries
const config = Object.create(null);
config.apiKey = 'secret'; // No prototype chain
// Use Map for user-controlled keys
const userData = new Map();
userData.set(userKey, userValue); // Safe from pollution
Why this works:
- The reviver drops
__proto__,constructorandprototypeat parse time, so the dangerous key never survives to reach a later merge. Returningundefinedfrom a reviver deletes the property rather than setting it toundefined. Object.create(null)removes the prototype chain of that object, so a write to__proto__on it creates an ordinary key instead of reaching a prototype. It protects the dictionary, not a walk through it: a recursive merge that creates an intermediate{}along the way still pollutes through that one. See CWE-1321.Mapkeeps user-controlled keys off object properties entirely, which also avoids collisions with inherited names such astoString.- Verify it in both directions:
safeParse('{"__proto__":{"x":1}}')returns{}and({}).xstaysundefined, whilesafeParse('{"name":"alice","age":30}')still returns both fields. A reviver that strips too much is the easy mistake here.
Class-based Deserialization
// SECURE - Explicit class construction
class User {
constructor(name, email, age) {
this.name = name;
this.email = email;
this.age = age;
}
static fromJSON(json) {
const data = JSON.parse(json);
// Validate
if (typeof data.name !== 'string' || data.name.length > 100) {
throw new Error('Invalid name');
}
if (typeof data.email !== 'string' || !isValidEmail(data.email)) {
throw new Error('Invalid email');
}
if (typeof data.age !== 'number' || data.age < 0 || data.age > 150) {
throw new Error('Invalid age');
}
return new User(data.name, data.email, data.age);
}
toJSON() {
return {
name: this.name,
email: this.email,
age: this.age
};
}
}
// Usage:
const json = '{"name":"John","email":"john@example.com","age":30}';
const user = User.fromJSON(json);
Why this works:
- Separates parsing from object construction.
- Validates each field before creating the instance, so invalid data never reaches anything that acts on it.
- Blocks arbitrary class instantiation.
- Makes accepted inputs explicit and auditable.
TypeScript with Class Transformer
// SECURE - Type-safe deserialization with validation
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { IsString, IsEmail, IsInt, Length, Min, Max, validateOrReject } from 'class-validator';
class User {
@IsString()
@Length(1, 100)
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(0)
@Max(150)
age: number;
}
async function deserializeUser(json: string): Promise<User> {
const data = JSON.parse(json);
// plainToInstance, not plainToClass - the older name is deprecated in
// class-transformer 0.5 and is the same function under a different label
const user = plainToInstance(User, data);
// Validate. Throws an array of ValidationError, not an Error
await validateOrReject(user);
return user;
}
// Usage:
try {
const user = await deserializeUser(untrustedJson);
// user is validated User instance
} catch (errors) {
console.error('Validation failed:', errors);
}
Why this works:
plainToInstance()only shapes data into the target type. It reads no type metadata from the payload, so it cannot be steered into constructing a different class.validateOrReject()enforces the runtime constraints the decorators declare.- Fails fast, before the data reaches anything that acts on it.
This needs "experimentalDecorators": true in tsconfig.json; without it the
decorators are a compile error (TS1240), which is the harmless way to get this
wrong. Add "emitDecoratorMetadata": true and the reflect-metadata import
before using @Type() for nested objects or arrays, where the decorator has to
recover the element type from emitted metadata.
Note also what this does not do: plainToInstance copies properties the class
never declared, so {"name":"Alice","email":"a@b.com","age":30,"isAdmin":true}
produces an instance carrying isAdmin and validateOrReject accepts it -
there is no rule against a field nobody declared. Pass
excludeExtraneousValues: true with @Expose() on each field if you need
unknown keys dropped, and assert it: the same payload should come back with
three properties, not four.
Framework-Specific Guidance
Express.js
// SECURE - Express with built-in JSON parser
const express = require('express');
const app = express();
// Use built-in JSON parser (safe)
app.use(express.json({ limit: '1mb' }));
app.post('/api/users', async (req, res) => {
// req.body is parsed JSON
const { name, email, age } = req.body;
// Validate input
if (!name || typeof name !== 'string' || name.length > 100) {
return res.status(400).json({ error: 'Invalid name' });
}
if (!email || !isValidEmail(email)) {
return res.status(400).json({ error: 'Invalid email' });
}
if (typeof age !== 'number' || age < 0 || age > 150) {
return res.status(400).json({ error: 'Invalid age' });
}
const user = { name, email, age };
await saveUser(user);
res.json(user);
});
// For cookies, use signed cookies:
const cookieParser = require('cookie-parser');
app.use(cookieParser('your-secret-key'));
app.post('/login', (req, res) => {
const user = { id: 123, name: 'John' };
// Signed cookie (tamper-proof)
res.cookie('user', JSON.stringify(user), {
signed: true,
httpOnly: true,
secure: true,
sameSite: 'strict'
});
});
app.get('/profile', (req, res) => {
// Verify signature
const userJson = req.signedCookies.user;
if (!userJson) {
return res.status(401).send('Unauthorized');
}
const user = JSON.parse(userJson);
res.json(user);
});
NestJS
// SECURE - NestJS with class-validator
import { Controller, Post, Body } from '@nestjs/common';
import { IsString, IsEmail, IsInt, Length, Min, Max } from 'class-validator';
class CreateUserDto {
@IsString()
@Length(1, 100)
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(0)
@Max(150)
age: number;
}
@Controller('users')
export class UsersController {
@Post()
async create(@Body() createUserDto: CreateUserDto) {
// Validated by the ValidationPipe registered in main.ts below,
// not by the decorators alone
const user = await this.usersService.create(createUserDto);
return user;
}
}
The decorators on the DTO declare rules; nothing validates until a
ValidationPipe is bound, and a NestJS application without one accepts every
body the type annotation claims to constrain - the DTO reads as a control and is
only documentation. Register it once at the application level:
// main.ts - SECURE - without this, the decorators above are inert
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip properties no DTO declares
forbidNonWhitelisted: true, // ...and reject the request rather than silently dropping them
transform: true, // build a real DTO instance, not a plain object
}));
await app.listen(3000);
}
bootstrap();
Next.js API Routes
// SECURE - Next.js API routes
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// req.body is already parsed JSON
const { name, email, age } = req.body;
// Validate
if (typeof name !== 'string' || name.length === 0 || name.length > 100) {
return res.status(400).json({ error: 'Invalid name' });
}
if (typeof email !== 'string' || !email.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}
if (typeof age !== 'number' || age < 0 || age > 150) {
return res.status(400).json({ error: 'Invalid age' });
}
const user = { name, email, age };
await saveUser(user);
res.status(201).json(user);
}
Input Validation with Joi
// SECURE - Schema validation with Joi
const Joi = require('joi');
const userSchema = Joi.object({
name: Joi.string().min(1).max(100).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(150).required()
// SECURE - convert:false turns off Joi's coercion. Left on, the string "42"
// is accepted and handed to the application as the number 42
}).options({ convert: false });
app.post('/users', async (req, res, next) => {
try {
// Validate against schema
const user = await userSchema.validateAsync(req.body);
// user is validated and safe
await saveUser(user);
res.json(user);
} catch (err) {
// Only a validation failure is a 400. Letting saveUser's errors fall
// into this branch would report a database outage as bad input.
if (!err.isJoi) return next(err);
// SECURE - the detail goes to the log, the caller gets a fixed body.
// Joi's message names the failing path, so returning it hands a caller
// probing field names an inventory of the schema.
console.warn('validation failed', {
paths: err.details.map((d) => d.path.join('.')),
message: err.message,
});
res.status(400).json({ error: 'Invalid request' });
}
});
Why this works: measured on Joi 18.2.5, validateAsync rejects with an
error carrying isJoi: true and a details array, and its message is
"email" must be a valid email, "email" is required or "extra" is not
allowed - each naming a field the caller did not have to know existed.
Returning that verbatim is CWE-209 layered on top of
the fix this page is about, and it is what the Errors reveal nothing
structural assertion in Testing below is checking for. The log line keeps
every bit of it for whoever has to debug the rejection.
convert: false is the other half, and it is the option most often left at its
default. Joi coerces by design: measured on the same version, this schema
without it accepts {"age":"42"} and hands saveUser the number 42, so a
type the schema appears to require was never actually required. It belongs on a
JSON endpoint, where the format carries types. A form-encoded body does not -
everything arriving through express.urlencoded is a string, and
convert: false there rejects input that is perfectly valid - so parse those
fields explicitly at the edge rather than turning coercion back on for the
whole schema.
Signature Verification with JWT
// SECURE - Use JWT for signed data
const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.JWT_SECRET;
// Create signed token
function createToken(user) {
return jwt.sign(
{ id: user.id, name: user.name, role: user.role },
SECRET_KEY,
{ expiresIn: '1h' }
);
}
// Verify and decode token
function verifyToken(token) {
try {
// Pin the algorithm. Without it the library accepts any algorithm the
// key type supports, so a token minted as HS512 verifies against an
// HS256 deployment.
const decoded = jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] });
return decoded;
} catch (err) {
throw new Error('Invalid token');
}
}
// Usage in Express:
app.post('/login', (req, res) => {
const user = authenticateUser(req.body);
const token = createToken(user);
res.json({ token });
});
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
try {
const user = verifyToken(token);
res.json(user);
} catch (err) {
res.status(401).json({ error: 'Unauthorized' });
}
});
Common Pitfalls
- Assuming
JSON.parse()alone is "safe" and then deep-merging the parsed object into application state or config with a custom recursive merge or aset(obj, path, value)helper. A payload containing a__proto__orconstructor.prototypekey pollutesObject.prototypethrough the merge step - a different attack surface than theeval()/node-serializeRCE this fix removed, but still a way for untrusted data to corrupt application-wide behavior. Reaching for a maintained utility instead of hand-rolling it is most of the fix: on lodash 4.18.1,_.merge,_.defaultsDeepand_.setall refuse__proto__. - Validating request bodies with a schema library (Joi, Zod, class-validator) on the main REST routes while leaving a webhook handler, a WebSocket message handler, or a GraphQL resolver parsing the same kind of JSON payload without going through the same schema - these secondary entry points are where an unvalidated
JSON.parse()or a leftovereval()-based parser tends to survive after the primary controller passes review. - Calling
jwt.verify(token, secret)without pinningalgorithms: [...], and assuming the classic bypasses are what it costs you. Measured onjsonwebtoken9.0.3, the library already refusesalg: nonewhen a key is supplied and already refuses an HS256 token verified against an RSA public key - it derives the permitted family from the key type, which is what 9.0.0 changed. What is still open without the pin is cross-algorithm acceptance within a family: a token minted as HS512 verifies against a deployment that only ever issues HS256. Pin it anyway, and if you are onjsonwebtoken8.x or an older library, the classic bypasses are live and the pin is the only thing stopping them. - Moving parsed JSON safely past
JSON.parse()and then feeding it into a template engine's raw-eval mode, a "custom formula" feature built onnew Function(), or an unsandboxedvm.runInNewContext()further down the request - the deserialization call itself is safe, but the code-execution risk just moved one step down the data flow instead of being removed.
Testing
A scanner can see that a dangerous deserializer is gone. It cannot see whether the replacement accepts only what you intended, which is the whole of the fix. Assert each of these:
- Valid input still parses. A well-formed payload produces the expected object with every field populated. This is the test that catches an over-tightened schema before users do.
- Unknown properties are rejected. Send a payload with an extra property
and assert it errors. Measured on Joi 18.2.5, the default is to fail -
"extra" is not allowed- which is what thevalidateAsyncexample above relies on;stripUnknown: trueis what removes the key silently, andallowUnknown: truelets it through untouched. Zod is the other way round, stripping unknown keys unless the schema asks for.strict(), so assert the behaviour of the library and options you actually configured rather than the one you assumed. A__proto__key does not take this path at all: measured on the same version, Joi drops it and reports no error, so an assertion built only on an ordinary extra property says nothing about the next bullet. - Prototype-polluting keys are rejected. Send
__proto__,constructor, andprototypeas property names and assert each is rejected or safely discarded, then assert({}).pollutedis stillundefinedafterwards. This is the JavaScript-specific failure that a generic schema check misses, and it can turn a merge into remote code execution. - Type confusion is rejected. Send a string where a number is expected, an
array where an object is expected, and
nullfor a required field. Each should error rather than coerce - JavaScript's implicit conversions make silent coercion the default outcome, and so does Joi's. Measured on Joi 18.2.5, the schema above accepts{"age":"42"}and returns42as a number untilconvert: falseis set; with it, all three cases fail with"age" must be a number. Assert this against the options you configured, not against the library's name. - Size and depth are bounded. Send a payload larger than the body limit
and a deeply nested one (several thousand levels of
[), and assert both are rejected before parsing completes.JSON.parseis synchronous, so a deeply nested payload blocks the event loop for every other request. - Errors reveal nothing structural. Assert the caller-visible message is generic. Joi's default messages name the failing path, which is useful in a log and an inventory of your schema in an HTTP response.