Skip to content

CWE-502: Insecure Deserialization - JavaScript / Node.js

Overview

JavaScript deserialization vulnerabilities occur when eval(), Function(), or vulnerable libraries parse untrusted data, allowing attackers to execute arbitrary code. Node.js applications are particularly vulnerable when deserializing from cookies, external APIs, or user uploads.

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 fn = 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.
// VULNERABLE - Unsafe merge enables prototype pollution
const data = JSON.parse(req.body);

// Later: merge untrusted data into a target object
Object.assign(userProfile, data);

// Attacker sends: {"__proto__": {"isAdmin": true}}
// Now ALL objects can appear to have isAdmin: true

if (user.isAdmin) {  // Can be true for any user!
    grantAdminAccess();
}

Why this is vulnerable:

  • Pollution typically occurs when parsed data is merged into objects.
  • __proto__, constructor, and prototype keys can alter prototypes.
  • Bypasses auth checks via injected flags.
  • Can alter behavior across the app.

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 dynamic type or class instantiation.
  • 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:

  • Freezing prototypes blocks mutation attacks.
  • Reviver filters dangerous keys at parse time.
  • Object.create(null) removes the prototype chain.
  • Map avoids inherited properties entirely.
  • Prevents global behavior changes from input.

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.
  • Blocks arbitrary class instantiation.
  • Makes accepted inputs explicit and auditable.
  • Rejects invalid data early.

TypeScript with Class Transformer

// SECURE - Type-safe deserialization with validation
import { plainToClass, Type } from 'class-transformer';
import { IsString, IsEmail, IsInt, 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);
    const user = plainToClass(User, data);

    // Validate
    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:

  • plainToClass() only shapes data into the target type.
  • validateOrReject() enforces runtime constraints.
  • Prevents invalid or out-of-range values.
  • Keeps rules close to the model definition.
  • Fails fast before data is used.

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, 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) {
        // NestJS automatically validates with class-validator
        // createUserDto is type-safe and validated

        const user = await this.usersService.create(createUserDto);
        return user;
    }
}

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()
});

app.post('/users', async (req, res) => {
    try {
        // Validate against schema
        const user = await userSchema.validateAsync(req.body);

        // user is validated and safe
        await saveUser(user);
        res.json(user);

    } catch (err) {
        res.status(400).json({ error: err.message });
    }
});

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 {
        const decoded = jwt.verify(token, SECRET_KEY);
        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' });
    }
});

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use safe deserialization APIs and reject unsafe formats
  • Static analysis: Use security scanners to verify no unsafe deserialization patterns remain
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources