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:
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
// 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, andprototypekeys 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.Mapavoids 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