open atlas
↑ Back to track
NestJS, zero to senior NEST · 02 · 04

Whitelisting, nested validation and partial updates

Hardening ValidationPipe past the basics: whitelist strips undecorated fields to kill mass-assignment, but only as one layer; nested DTOs need @ValidateNested + @Type or validation silently no-ops; PATCH needs PartialType, not skipMissingProperties.

NEST Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The registration DTO was textbook: @IsEmail() on email, @MinLength(8) on password, tests green, code review approved. The validation worked perfectly — it validated exactly the two fields it was told about. Three weeks after launch, a support ticket: a user account with role: 'admin'. Nobody had granted it. The audit log showed a plain POST /auth/register with a body the frontend never sends: { "email": "...", "password": "...", "role": "admin", "isVerified": true }. The service did this.userRepo.save(dto). class-validator saw two decorated fields, validated them, and waved the whole object through — including the two fields it had never heard of. They rode straight into the saved entity. The DTO didn’t have a bug. It had a boundary that leaked everything it wasn’t explicitly told to stop. This lesson is about closing that boundary: whitelisting, nested validation, and partial updates.

whitelist: the field that was never supposed to be there

By default ValidationPipe validates the decorated properties and leaves everything else on the object. A field with no decorator isn’t rejected — it’s ignored by the validator and passed through untouched. That is the entire mechanism behind the incident: an undecorated role is invisible to validation but fully present in the payload your service hands to the ORM.

// main.ts — the hardened global pipe
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,            // strip any property with NO validation decorator
    forbidNonWhitelisted: true, // …and 400 instead of silently stripping
    transform: true,            // instantiate the DTO class (also needed for @Type)
  }),
);

whitelist: true flips the default: any property that has no validation decorator on the DTO is stripped off the object before it reaches your handler. The undecorated role and isVerified simply cease to exist. forbidNonWhitelisted: true goes one step further — instead of silently dropping the unknown field, the pipe throws a 400 Bad Request naming it (property role should not exist). Silent stripping is safer-by-default; the forbid variant is louder and catches clients (and your own frontend) sending fields they shouldn’t, which is what you want in a service where an unexpected field is a bug, not noise.

// RegisterDto — only these two fields survive whitelist; role/isVerified get stripped
export class RegisterDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
  // note: NO role, NO isVerified — so whitelist removes them from the body
}

This is mass assignment (a.k.a. over-posting) — OWASP API3:2023, Broken Object Property Level Authorization. The attacker doesn’t break your validation; they exploit a field your validation never modeled. whitelist strips it at the door.

Why this works

Why isn’t whitelist: true enough on its own? Because whitelist only strips fields that have no decorator. The day someone adds a decorator to role for an unrelated admin endpoint that shares the DTO — or you write userRepo.save({ ...dto, createdAt }) and the spread re-introduces attacker-controlled keys — the boundary leaks again. whitelist protects the shape of the DTO, not the use of it. The durable fix is defense in depth: whitelist to strip, plus never persist the raw DTO. Map explicit fields into the entity (new User(); user.email = dto.email; user.passwordHash = hash(dto.password)) so the only properties that can reach the database are the ones you wrote by hand. save(dto) and { ...dto } are the two lines that turn a validation gap into a privilege escalation.

Nested validation: the decorator that does nothing

class-validator does not recurse into nested objects by default. Decorate city inside an AddressDto, nest that AddressDto inside RegisterDto, and the validation on city is silently skipped. The parent sees address as an opaque value and never descends into it. Invalid nested data — a city that’s a number, a missing required field — sails through.

import { Type } from 'class-transformer';
import { ValidateNested, IsString, IsArray, ArrayMinSize } from 'class-validator';

class AddressDto {
  @IsString() city: string;   // ← validated ONLY if the parent opts in below
  @IsString() country: string;
}

export class RegisterDto {
  @IsEmail() email: string;

  @ValidateNested()           // recurse into the nested object
  @Type(() => AddressDto)     // class-transformer must instantiate AddressDto first
  address: AddressDto;

  @ValidateNested({ each: true }) // recurse into EACH element of the array
  @Type(() => RoleDto)
  @IsArray()
  @ArrayMinSize(1)
  roles: RoleDto[];
}

Two decorators make nested validation actually run, and both are required. @ValidateNested() tells the validator to descend. @Type(() => AddressDto) tells class-transformer to instantiate a real AddressDto from the plain JSON — without it, address stays a plain object, the validator finds no class metadata to check against, and the nested rules are a no-op. Forgetting @Type is the classic silent failure: no error, no warning, validation just quietly doesn’t happen. For arrays of objects, add { each: true } so the recursion applies to every element, and pair @IsArray() / @ArrayMinSize() to constrain the array itself.

Partial updates: PUT replaces, PATCH patches

A PUT replaces the resource — every field is expected. A PATCH updates some fields and the rest are absent by design. Reuse the create DTO for a PATCH handler and every @IsEmail(), @MinLength() now wrongly demands a field the client deliberately omitted: a partial update fails validation for being partial.

import { PartialType } from '@nestjs/mapped-types'; // or @nestjs/swagger

// every field of CreateUserDto becomes optional, decorators preserved
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// PATCH /users/:id  — body { "email": "new@x.com" } alone now validates
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
  return this.users.update(id, dto);
}

PartialType(CreateUserDto) builds a new DTO where every inherited field is wrapped as optional (it applies @IsOptional() semantics field-by-field) while keeping the original decorators for any field that is present. It’s surgical: absent fields are fine, present fields still validate. The tempting shortcut — new ValidationPipe({ skipMissingProperties: true }) — works but is a blunt instrument: it skips validation for any missing property across every DTO in the app, including required fields you never meant to make optional, so a genuinely-required-but-forgotten field now passes silently. PartialType scopes optionality to exactly the one update DTO. A related subtlety: @IsOptional() skips validation when the value is null/undefined, which is what makes “field absent” valid — but it also means you can’t distinguish “client didn’t send bio” from “client sent bio: null to clear it” without a separate @IsDefined()/@ValidateIf guard.

Why this works

Why map fields explicitly when whitelist already stripped role? Because whitelist and explicit mapping fail in different ways, so together they cover each other. whitelist fails the moment the DTO shape changes — a decorator added for an unrelated reason, a shared DTO, a { ...dto } spread that re-introduces keys downstream. Explicit mapping fails… basically never, because the entity only ever receives the literal assignments you typed. The cost of explicit mapping is a few lines of boilerplate per create/update path. The cost of relying on whitelist alone is that one refactor three modules away silently reopens the privilege-escalation hole. Defense in depth means no single change to the DTO can be a security regression.

Pick the best fit

A public registration endpoint must never let a client set their own role (the role column exists on the User entity). Which approach is the soundest defense against mass assignment?

Quiz

A RegisterDto decorates only email and password. A request body includes an extra `role: 'admin'`. What does the ValidationPipe do under whitelist: true versus forbidNonWhitelisted: true?

Quiz

An AddressDto has @IsString() on city. It's nested as `address: AddressDto` inside RegisterDto with no other decorators on the field. A request sends `address: { city: 12345 }`. What happens?

Recall before you leave
  1. 01
    Explain the mass-assignment vulnerability in a NestJS registration endpoint: how it happens, what whitelist does, why whitelist alone is not enough, and the full fix.
  2. 02
    How do you make nested-object validation actually run, and how do you correctly support PATCH partial updates? Cover the silent failures.
Recap

Hardening ValidationPipe past basic DTO decorators closes three boundaries. First, mass assignment: by default the pipe validates decorated fields and leaves undecorated ones on the object, so a registration DTO that only decorates email and password lets an attacker’s role: ‘admin’ ride into userRepo.save(dto) — OWASP API3:2023, a privilege escalation. whitelist: true strips any property with no validation decorator; forbidNonWhitelisted: true 400s instead of silently stripping. But whitelist guards the DTO’s shape, not its use, so the real fix is defense in depth — whitelist PLUS explicit field mapping into the entity, never save(dto) or spread the raw DTO. Second, nested validation: class-validator does not recurse by default, so a decorator on a nested DTO’s field is a silent no-op unless the parent field carries both @ValidateNested() (descend) and @Type(() => NestedDto) (instantiate); arrays of objects add { each: true } plus @IsArray()/@ArrayMinSize(). Third, partial updates: a PUT replaces and a PATCH patches, so reusing the create DTO wrongly requires omitted fields; UpdateUserDto extends PartialType(CreateUserDto) makes every field optional surgically, while skipMissingProperties: true is the blunt global alternative that relaxes required fields you never meant to. Validation that only checks the fields you named is a boundary that leaks everything you didn’t. Now when you see a save(dto) call or a nested object with no @ValidateNested, you know the two silent holes that are open — and exactly what to add to close them.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.