Skip to content

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:

  • vm is not a security sandbox.
  • Constructor chains can escape the context.
  • Access to process enables command execution.
  • Runs attacker code inside the same process.

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.isAdmin becomes true and no other object changes. It cannot reach Object.prototype because it does not recurse.
  • A maintained utility library is not the example here. Measured on lodash 4.18.1, _.merge, _.defaultsDeep and _.set all leave Object.prototype untouched - 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__, constructor and prototype at parse time, so the dangerous key never survives to reach a later merge. Returning undefined from a reviver deletes the property rather than setting it to undefined.
  • 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.
  • Map keeps user-controlled keys off object properties entirely, which also avoids collisions with inherited names such as toString.
  • Verify it in both directions: safeParse('{"__proto__":{"x":1}}') returns {} and ({}).x stays undefined, while safeParse('{"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 a set(obj, path, value) helper. A payload containing a __proto__ or constructor.prototype key pollutes Object.prototype through the merge step - a different attack surface than the eval()/node-serialize RCE 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, _.defaultsDeep and _.set all 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 leftover eval()-based parser tends to survive after the primary controller passes review.
  • Calling jwt.verify(token, secret) without pinning algorithms: [...], and assuming the classic bypasses are what it costs you. Measured on jsonwebtoken 9.0.3, the library already refuses alg: none when 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 on jsonwebtoken 8.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 on new Function(), or an unsandboxed vm.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 the validateAsync example above relies on; stripUnknown: true is what removes the key silently, and allowUnknown: true lets 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, and prototype as property names and assert each is rejected or safely discarded, then assert ({}).polluted is still undefined afterwards. 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 null for 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 returns 42 as a number until convert: false is 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.parse is 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.

Additional Resources