Input Safety

Accept only the properties you declared, cap how much body you will parse, and keep a request value from choosing where an outbound call goes.

Syntaxnew ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })

Three different things arrive from outside: a shape, a size, and sometimes a destination. Each needs its own limit.

Shape: whitelisting

ValidationPipe validates a DTO with class-validator. Two options turn it from a validator into a filter. whitelist: true strips every property that carries no validation decorator, so an extra role or isAdmin in the body never reaches the handler and never reaches an ORM's Object.assign. Adding forbidNonWhitelisted: true rejects the request with a 400 instead of stripping it quietly. It only works together with whitelist. As a pipe, it runs after guards and immediately before the handler, once per decorated parameter.

Size: the body parser

Parsing happens before any of your code. Express buffers up to 100kb by default and Fastify up to 1 MiB; a larger body is rejected by the parser. Raise the limit per parser with app.useBodyParser('json', { limit: '10mb' }) on Express, or { bodyLimit } in bytes on Fastify. Lowering it is the cheaper defence: an endpoint that only ever takes a short JSON object has no reason to accept megabytes.

Destination: SSRF

Server-side request forgery is when a value from the request decides where your server sends its next request, so the caller reaches your metadata service or an internal admin port through you. The rule is that a user-supplied value may fill a path segment or a query parameter, never the host. In NestJS 12, @nestjs/http-client enforces this: a client configured with a baseUrl throws before sending if an absolute URL points at another origin, path parameters are URI-encoded, and a value such as .. throws rather than moving the request.

Example

Example · typescript
// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module.js';

async function bootstrap() {
  // The generic is required for useBodyParser() to exist on the type.
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // Express parses up to 100kb by default. 64kb is plenty for JSON and
  // keeps a 50MB payload from being buffered before anything inspects it.
  // Raise it per route group instead if one endpoint needs more.
  app.useBodyParser('json', { limit: '64kb' });
  app.useBodyParser('urlencoded', { limit: '64kb', extended: true });

  app.useGlobalPipes(
    new ValidationPipe({
      // Strip every property with no class-validator decorator.
      whitelist: true,
      // ...and reject rather than strip. Has no effect without whitelist.
      forbidNonWhitelisted: true,
      // Turn the plain parsed body into an instance of the DTO class, and
      // coerce path/query strings to the declared parameter types.
      transform: true,
      // Do not echo property names and constraint messages to the client
      // in production; the 400 body becomes empty of detail.
      disableErrorMessages: process.env.NODE_ENV === 'production',
      // Stop at the first failing constraint per property, so a long
      // string does not produce twenty messages.
      stopAtFirstError: true,
    }),
  );

  await app.listen(process.env.PORT ?? 3000);
}

await bootstrap();

When to use it

  • Keep a caller from setting their own role by adding a field to the sign-up body.
  • Reject a 40MB JSON body at the parser instead of buffering it into memory.
  • Cap ?limit= so one request cannot ask for the whole products table.
  • Make sure a tenant id from a request body cannot redirect an outbound call to an internal address.

More examples

A DTO that whitelisting actually protects

This is the DTO side of whitelisting, and the nested case is where it most often fails. A plain nested object with no @ValidateNested() and no @Type() is passed through as-is: its own unknown properties are never stripped and its constraints are never checked, even with whitelist: true set globally. The upper bound on password length is the other habit worth keeping, since every bcrypt or argon2 call is CPU you are handing to the caller.

Example · typescript
// src/users/dto/create-user.dto.ts
import { Type } from 'class-transformer';
import {
  IsEmail,
  IsInt,
  IsOptional,
  IsString,
  Length,
  Max,
  Min,
  ValidateNested,
} from 'class-validator';

class AddressDto {
  @IsString()
  @Length(1, 120)
  line1!: string;

  @IsString()
  @Length(2, 2)
  country!: string;
}

export class CreateUserDto {
  @IsEmail()
  email!: string;

  @IsString()
  @Length(12, 200) // an upper bound too: hashing a 1MB password is a DoS
  password!: string;

  @IsOptional()
  @IsInt()
  @Min(13)
  @Max(130)
  age?: number;

  // Nested objects are NOT validated without both of these. @Type() tells
  // class-transformer which class to build, @ValidateNested() tells
  // class-validator to walk into it - and whitelist only strips unknown
  // properties inside an object it was told to walk into.
  @ValidateNested()
  @Type(() => AddressDto)
  address!: AddressDto;

  // No decorator on purpose: with whitelist: true this property is
  // stripped from every request, so a caller cannot set their own role.
  // role?: string;
}

An outbound client a request value cannot redirect

Reach for a named client with a baseUrl for every upstream you call with credentials: the base URL is the allowlist. The interesting mistake it prevents is the one that looks harmless - building the path with a template string, so a tenantId of ../../admin/users silently changes the endpoint. @nestjs/http-client is new in NestJS 12 and replaces the Axios-based HttpModule from @nestjs/axios, which is still available; maxRedirects: 0 there corresponds to redirect: 'manual' here.

Example · typescript
// src/webhooks/webhooks.module.ts
import { Module } from '@nestjs/common';
import { HttpClientModule } from '@nestjs/http-client';
import { CallbackService } from './callback.service.js';

@Module({
  imports: [
    // A client with a baseUrl only sends requests to that origin: an
    // absolute URL on another host throws before anything leaves the
    // process. That is what keeps a body value from pointing this
    // client's API key at 169.254.169.254 or at an internal admin port.
    HttpClientModule.register({
      name: 'partner',
      baseUrl: 'https://hooks.partner.example.com',
      headers: { 'x-api-key': process.env.PARTNER_API_KEY! },
      timeout: '5s',
      // fetch drops authorization and cookie when it follows a redirect
      // to another origin, but not a custom header like x-api-key.
      // 'error' fails with HttpNetworkError instead of leaking the key.
      redirect: 'error',
    }),
  ],
  providers: [CallbackService],
})
export class WebhooksModule {}

// src/webhooks/callback.service.ts
import { Injectable } from '@nestjs/common';
import { HttpClient, InjectHttpClient } from '@nestjs/http-client';

@Injectable()
export class CallbackService {
  constructor(
    @InjectHttpClient('partner') private readonly partner: HttpClient,
  ) {}

  async notify(tenantId: string, event: unknown) {
    // Request values go in params, never into the path by interpolation.
    // params fills the :name segments URI-encoded, and a value that would
    // move the request elsewhere ('', '.', '..') throws.
    await this.partner.post('/tenants/:tenantId/events', {
      params: { tenantId },
      json: event,
    });
  }
}

Validating what is not a body

Bodies get DTOs by habit and query strings usually do not, which is where unbounded page sizes and sort columns interpolated into SQL come from. The two details worth copying: a Max on limit, because that number becomes the size of a result set, an allowlist on anything that reaches an ORDER BY, and @Param() without an argument so the pipe receives an object it can validate against a class.

Example · typescript
// src/products/dto/find-products.query.ts
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';

export class FindProductsQuery {
  // Query and path values always arrive as strings. With transform: true
  // the declared type drives the conversion; @Type() makes it explicit
  // and survives a later change to the global pipe options.
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100) // the cap that stops ?limit=1000000 from scanning the table
  limit?: number = 20;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(0)
  offset?: number = 0;

  // An allowlist, because this value reaches an ORDER BY clause.
  @IsOptional()
  @IsIn(['name', 'price', 'createdAt'])
  sort?: string = 'createdAt';
}

// src/products/dto/product-params.ts
export class ProductParams {
  // Constrain the id too. It reaches the database, a cache key, and
  // sometimes a file path.
  @IsUUID('4')
  id!: string;
}

// src/products/products.controller.ts
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ProductsService } from './products.service.js';
import { FindProductsQuery } from './dto/find-products.query.js';
import { ProductParams } from './dto/product-params.js';

@Controller('products')
export class ProductsController {
  constructor(private readonly products: ProductsService) {}

  @Get()
  findAll(@Query() query: FindProductsQuery) {
    return this.products.findAll(query);
  }

  // @Param() with no argument hands the whole params object to the pipe,
  // which is what lets ProductParams validate it. @Param('id') would
  // give the pipe a bare string and the @IsUUID() would never run.
  @Get(':id')
  findOne(@Param() params: ProductParams) {
    return this.products.findOne(params.id);
  }
}

Discussion

  • Be the first to comment on this lesson.