DEV Community

Cover image for I Inherited a NestJS Codebase. The First Lint Run Found 6 Vulnerabilities.
Ofri Peretz
Ofri Peretz

Posted on • Originally published at ofriperetz.dev

I Inherited a NestJS Codebase. The First Lint Run Found 6 Vulnerabilities.

Code review checks for what's there. Static analysis checks for what's missing.

That asymmetry is why a codebase can have CI, tests, TypeScript strict mode, and two years of feature PRs — and still ship 6 distinct vulnerability classes that no reviewer caught. Not because reviewers were careless. Because every one of these bugs required noticing the absence of something: a missing decorator, a missing pipe, a missing guard. That's off the mental stack when you're reading route logic.

The first run of eslint-plugin-nestjs-security on a 40K-line production codebase took 12 seconds. It found 47 violations. Here are all 6 — and exactly why each one survived code review.


1. Unguarded Controllers (CWE-284)

What the code looked like:

@Controller('admin')
export class AdminController {
  @Get('users')
  async getAllUsers() {
    return this.usersService.findAll();
  }

  @Delete('user/:id')
  async deleteUser(@Param('id') id: string) {
    return this.usersService.delete(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why it survived review: The team believed a global JwtAuthGuard was configured in main.ts. It was configured on the AppModule — but a 6-month-old refactor broke the middleware ordering. No test caught this because the test suite mocked the guard globally.

What the lint rule catches: require-guards fires on any @Controller class or route handler that lacks @UseGuards(...) or a @Public() opt-out. No type inference needed — pure structural analysis.

// Fix: explicit guard at the controller level
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
  @Get('users')
  @Roles('admin')
  async getAllUsers() {
    return this.usersService.findAll();
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Sensitive Fields Leaking in Responses (CWE-200)

What the code looked like:

@Entity()
export class User {
  @Column() id: string;
  @Column() email: string;
  @Column() password: string;      // hashed, but still in the response
  @Column() refreshToken: string;  // full token, rotated monthly
}

@Get(':id')
async getUser(@Param('id') id: string): Promise<User> {
  return this.usersService.findOne(id); // entity returned directly
}
Enter fullscreen mode Exit fullscreen mode

Why it survived review: The entity was consumed exclusively by an internal gRPC service that deserialized it into a typed struct — stripping unknown fields silently on the client side. No API log, no Datadog response capture, no staging curl that would surface password in the body. The data left the server but never appeared anywhere the team looked. A penetration tester found it by running a raw HTTP client against the REST endpoint that was added three months later and never audited.

What the lint rule catches: no-exposed-private-fields scans class properties for sensitive field name patterns (password, secret, token, apiKey, refreshToken, ssn, creditCard, ...) and flags any that aren't decorated with @Exclude() from class-transformer.

import { Exclude } from 'class-transformer';

@Entity()
export class User {
  @Column() id: string;
  @Column() email: string;

  @Column()
  @Exclude()
  password: string;

  @Column()
  @Exclude()
  refreshToken: string;
}

// In main.ts:
// app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
Enter fullscreen mode Exit fullscreen mode

3. Auth Endpoints Without Rate Limiting (CWE-307)

What the code looked like:

@Controller('auth')
export class AuthController {
  @Post('login')
  async login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }

  @Post('reset-password')
  async resetPassword(@Body() dto: ResetPasswordDto) {
    return this.authService.resetPassword(dto);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why it survived review: The infra team planned to add rate limiting at the nginx layer. The nginx config was updated for /api/v1/auth/login — but the app prefix was changed to /api/v2 in the same sprint. Nobody cross-referenced the two PRs.

What the lint rule catches: require-throttler flags any @Controller or route handler that doesn't have @Throttle(...) or ThrottlerGuard in its guard chain.

// requires @nestjs/throttler@^4
@Controller('auth')
@UseGuards(ThrottlerGuard)
export class AuthController {
  @Post('login')
  @Throttle({ default: { limit: 5, ttl: 60000 } }) // 5 per minute
  async login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Unvalidated DTO Inputs (CWE-20)

What the code looked like:

@Post('typed')
async createPost(@Body() body: CreatePostDto) {
  return this.postsService.create(body);
}
Enter fullscreen mode Exit fullscreen mode

Why it survived review: CreatePostDto is typed. TypeScript enforces the shape at compile time. Reviewers saw a typed DTO and assumed validation was running. It wasn't — without a ValidationPipe, the TypeScript types are compile-time only. At runtime, any shape passes through.

What the lint rule catches: no-missing-validation-pipe flags @Body() parameters that lack new ValidationPipe() at the parameter level, and verifies that a global ValidationPipe is registered.

// Option 1: global (recommended) — in main.ts
app.useGlobalPipes(
  new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })
);

// Option 2: per-parameter
@Post()
async createPost(@Body(new ValidationPipe()) body: CreatePostDto) {
  return this.postsService.create(body);
}
Enter fullscreen mode Exit fullscreen mode

5. DTO Properties Without Validation Decorators (CWE-20)

What the code looked like:

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

  name: string;  // no validator — accepts any length, any encoding

  role: string;  // no validator — accepts 'admin' as easily as 'user'
}
Enter fullscreen mode Exit fullscreen mode

Why it survived review: email had a decorator, so the reviewer's eye treated the DTO as validated and moved on. One decorated field gave the whole class a passing grade. The role field was added three weeks later in a quick patch, never circled back to, and accepted because the surrounding context looked safe. When whitelist: true wasn't enforced at runtime, role: 'admin' passed through unchecked.

What the lint rule catches: require-class-validator verifies that every property in a DTO class has at least one class-validator decorator.

import { IsEmail, IsString, MaxLength, IsEnum } from 'class-validator';

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

  @IsString()
  @MaxLength(100)
  name: string;

  @IsEnum(UserRole) // 'user' | 'moderator' — not 'admin'
  role: UserRole;
}
Enter fullscreen mode Exit fullscreen mode

6. Debug Endpoints Left in Production (CWE-215)

What the code looked like:

@Controller('debug')
export class DebugController {
  @Get('config')
  getConfig() {
    return process.env; // DATABASE_URL, JWT_SECRET, STRIPE_SECRET_KEY, all of it
  }
}
Enter fullscreen mode Exit fullscreen mode

Why it survived review: It was protected by @UseGuards(JwtAuthGuard) — in staging. A NODE_ENV === 'production' check was meant to disable it but the condition was inverted. Deployed to production in a Friday afternoon push. Found by a user who noticed /debug/config returned valid JSON.

What the lint rule catches: no-exposed-debug-endpoints flags controllers with paths matching debug, internal, or _health that lack auth guards, and any endpoint that returns process.env directly.

// Fix: remove the controller entirely.
// If you need a health check for load balancers / k8s probes,
// use a dedicated module that never touches process.env:
@Controller('health')
export class HealthController {
  @Get()
  check() {
    return { status: 'ok', timestamp: Date.now() };
  }
}
// Note: health endpoints are intentionally public (LBs can't auth).
// The rule won't fire here because the path is 'health', not 'debug'.

Enter fullscreen mode Exit fullscreen mode

The config that catches all 6 in one pass

// eslint.config.mjs
import nestjsSecurity from 'eslint-plugin-nestjs-security';

export default [
  {
    plugins: { 'nestjs-security': nestjsSecurity },
    rules: {
      'nestjs-security/require-guards': 'error',
      'nestjs-security/no-exposed-private-fields': 'error',
      'nestjs-security/require-throttler': 'error',
      'nestjs-security/no-missing-validation-pipe': 'error',
      'nestjs-security/require-class-validator': 'warn',
      'nestjs-security/no-exposed-debug-endpoints': 'error',
    },
  },
];
Enter fullscreen mode Exit fullscreen mode
npm install --save-dev eslint-plugin-nestjs-security
npx eslint src/
Enter fullscreen mode Exit fullscreen mode

The connection between #4 and #5

Bugs 4 and 5 interact. whitelist: true on the ValidationPipe strips the role: 'admin' attack — but only if the pipe is actually registered (bug #4). Without it, even a perfectly decorated DTO is runtime-permissive. The two rules catch the issues independently; fixing one without the other leaves a gap. Run both checks.


Which of these six hit production before anyone noticed — and how did you find out? Drop the worst one below.


📦 eslint-plugin-nestjs-security — 6 security rules for NestJS

⭐ Star on GitHub


GitHub | X | LinkedIn | Dev.to

Top comments (0)