Cours NestJS · Module 9: Déploiement et infrastructure

Bonnes pratiques de sécurité - protection contre les barbares

7 min de lecture
Dans cette leçon6

Gardien des tributs ! Aux frontières de l'Empire, les barbares prennent rarement les murs d'assaut. Ils essaient des clés : des mots de passe volés sur d'autres services, des jetons falsifiés, des secrets que quelqu'un a laissés dans un dépôt. Le consul Caesar.js veut que tu connaisses les meilleures méthodes de défense contre ce genre d'attaques.

Authentication & Authorization

L'authentification vérifie qui est le légionnaire, et l'autorisation ce qu'il a le droit de faire. Une stratégie Passport vérifie le jeton JWT :

1// JWT Strategy
2import { Injectable } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { PassportStrategy } from '@nestjs/passport';
5import { ExtractJwt, Strategy } from 'passport-jwt';
6
7@Injectable()
8export class JwtStrategy extends PassportStrategy(Strategy) {
9  constructor(config: ConfigService) {
10    super({
11      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
12      ignoreExpiration: false,
13      secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
14      algorithms: ['HS256'], // n'accepte que l'algorithme avec lequel tu signes
15    });
16  }
17
18  async validate(payload: any) {
19    return { userId: payload.sub, username: payload.username };
20  }
21}

ExtractJwt.fromAuthHeaderAsBearerToken() lit le jeton dans l'en-tête Authorization, et ignoreExpiration: false rejette les jetons expirés. getOrThrow() empêche l'application de démarrer quand le secret manque, au lieu de tourner avec une clé vide. La liste algorithms bloque la substitution d'algorithme : dans le test, un jeton signé en HS512 a reçu un 401. Ce que renvoie validate() arrive dans req.user.

On ne stocke les mots de passe que sous forme de hachés :

1// Password hashing
2import { BadRequestException, Injectable } from '@nestjs/common';
3import * as bcrypt from 'bcrypt';
4
5@Injectable()
6export class CryptoService {
7  async hashPassword(password: string): Promise<string> {
8    // bcrypt ne lit que 72 octets - rejette un mot de passe plus long au lieu de le tronquer en silence
9    if (Buffer.byteLength(password, 'utf8') > 72) {
10      throw new BadRequestException('Le mot de passe dépasse 72 octets');
11    }
12    const saltRounds = 12;
13    return bcrypt.hash(password, saltRounds);
14  }
15
16  async validatePassword(password: string, hash: string): Promise<boolean> {
17    return bcrypt.compare(password, hash);
18  }
19}

bcrypt avec un coût de 12 est volontairement lent, ce qui complique le cassage des hachés. Il ne lit pourtant que les 72 premiers octets UTF-8 : dans un test, un mot de passe qui ne différait qu'après le 72e octet a passé la vérification, d'où le garde de longueur. Pour un nouveau projet, je te recommande Argon2id (le paquet argon2), que l'OWASP place en premier et qui n'a pas cette limite.

Input Validation

Les données d'entrée sont validées par un DTO avec les décorateurs de class-validator :

1// DTO with validation
2import { IsEmail, IsString, Length, Matches, MaxLength, MinLength } from 'class-validator';
3
4export class CreateLegionaryDto {
5  @IsString()
6  @Length(2, 50)
7  @Matches(/^[\p{L}\s'-]+$/u, { message: 'Name can only contain letters' })
8  name: string;
9
10  @IsEmail()
11  email: string;
12
13  @IsString()
14  @MinLength(15) // NIST SP 800-63B-4 : la longueur plutôt que des règles de composition
15  @MaxLength(64)
16  password: string;
17}

La classe \p{L} avec le flag u accepte les lettres de tous les alphabets, donc « Łukasz Żółtowski » passe : la première version avec [a-zA-Z] rejetait les prénoms polonais. Les règles du mot de passe viennent du NIST SP 800-63B-4 : au moins 15 caractères quand le mot de passe est le seul facteur de connexion (8 avec la MFA), accepter au moins 64 caractères, et vérifier le mot de passe contre une liste de mots de passe ayant fuité. Avec bcrypt, souviens-toi que 64 caractères polonais peuvent dépasser 72 octets.

La version précédente de la leçon imposait une composition du mot de passe :

1// L'ancienne règle de composition du mot de passe - déconseillée aujourd'hui
2@Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
3  message: 'Password must contain uppercase, lowercase, number and special character'
4})

Le NIST interdit aujourd'hui explicitement ce genre de règles. Les gens y répondent par un « MotDePasse123! » prévisible, alors qu'une longue phrase ordinaire est plus difficile à casser.

Les secrets - les clés du trésor

La gestion des secrets en production repose sur trois principes : les secrets sont chiffrés, renouvelés régulièrement, et l'accès à ces secrets est audité. Garde-les dans un gestionnaire de secrets, par ex. HashiCorp Vault ou un service du cloud ; les variables d'environnement ne sont qu'un moyen de les transmettre au processus. Un secret ne va jamais dans le code, le dépôt ou une image Docker.

Le mieux est de détecter un secret manquant dès le démarrage. ConfigModule.forRoot() accepte un validationSchema, par ex. de la bibliothèque Joi, et vérifie les variables avant que l'application ne démarre :

1// app.module.ts - validation de la configuration au démarrage
2import { Module } from '@nestjs/common';
3import { ConfigModule } from '@nestjs/config';
4import Joi from 'joi';
5
6@Module({
7  imports: [
8    ConfigModule.forRoot({
9      validationSchema: Joi.object({
10        NODE_ENV: Joi.string()
11          .valid('development', 'production', 'test')
12          .default('development'),
13        PORT: Joi.number().port().default(3000),
14        DATABASE_URL: Joi.string().uri().required(),
15        JWT_SECRET: Joi.string().min(32).required(),
16      }),
17    }),
18  ],
19})
20export class AppModule {}

Un DATABASE_URL manquant et un JWT_SECRET trop court arrêtent le démarrage avec une Config validation error qui liste tous les problèmes d'un coup, et PORT arrive déjà sous forme de nombre. Dans un projet en modules ES, utilise import Joi from 'joi' : la forme import * as Joi compile, mais dans le test elle a échoué avec l'erreur Joi.string is not a function. La documentation de NestJS recommande aujourd'hui la bibliothèque Zod pour les nouveaux projets, et prend en charge Joi à partir de la version 18.

Des murs autour de l'application

Tu durcis les conteneurs comme dans la leçon sur Docker : un utilisateur autre que root, une image de base minimale et l'analyse des vulnérabilités. Fixe aussi les versions des images au lieu du tag :latest, qui change sans prévenir et empêche de reproduire exactement ce qui tourne en production. Le rate limiting de la leçon sur TLS protège contre un trop grand nombre de requêtes d'un même client.

La chronique de la garde

Sans logs, tu ne détecteras pas une intrusion. Les logs de production sont écrits par Winston via le module nest-winston :

1// app.module.ts - la chronique de la garde en production
2import { Module } from '@nestjs/common';
3import { WinstonModule } from 'nest-winston';
4import * as winston from 'winston';
5import DailyRotateFile from 'winston-daily-rotate-file';
6
7@Module({
8  imports: [
9    WinstonModule.forRoot({
10      format: winston.format.combine(
11        winston.format.timestamp(),
12        winston.format.json(),
13      ),
14      transports: [
15        new DailyRotateFile({
16          filename: 'logs/app-%DATE%.log',
17          datePattern: 'YYYY-MM-DD',
18          maxFiles: '30d',
19        }),
20        new DailyRotateFile({
21          filename: 'logs/error-%DATE%.log',
22          datePattern: 'YYYY-MM-DD',
23          level: 'error',
24          maxFiles: '90d',
25        }),
26      ],
27    }),
28  ],
29})
30export class AppModule {}

WinstonModule.forRoot() accepte les mêmes options que winston.createLogger(). Les logs sont en JSON, les fichiers tournent chaque jour, et les erreurs ont un fichier séparé avec une rétention plus longue. Pour que NestJS écrive aussi ses logs avec lui, appelle app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)) dans main.ts. N'écris jamais de mots de passe ni de jetons dans les logs.

Quand les barbares franchissent le mur

La gestion d'un incident de production suit des étapes fixes :

  1. détection et alerte sur le problème,
  2. triage et réponse initiale,
  3. atténuation et résolution du problème,
  4. analyse post-mortem et améliorations.

Un post-mortem cherche les causes dans le processus, pas des coupables, car c'est seulement ainsi que les gens disent la vérité. Je te recommande de noter ces étapes dans un runbook avant le premier incident. Dans la prochaine leçon, nous préparerons des sauvegardes pour le jour où le mur finira par tomber.

Retiens : le meilleur garde ne fait pas confiance à la porte, il vérifie chaque clé et note chaque tentative.

Code de cette leçon : src/security.ts
1// Bonnes pratiques de sécurité - protection contre les pirates
2import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
3
4// 1. JWT Authentication
5// @Injectable()
6// class JwtAuthGuard extends AuthGuard('jwt') {
7//   canActivate(context: ExecutionContext) {
8//     return super.canActivate(context);
9//   }
10// }
11
12// 2. Role-based Authorization
13@Injectable()
14class RolesGuard implements CanActivate {
15  constructor(private requiredRoles: string[]) {}
16
17  canActivate(context: ExecutionContext): boolean {
18    const request = context.switchToHttp().getRequest();
19    const user = request.user;
20
21    if (!user || !user.roles) return false;
22
23    return this.requiredRoles.some(role =>
24      user.roles.includes(role)
25    );
26  }
27}
28
29// 3. Input Validation (class-validator)
30// class CreateLegionDto {
31//   @IsString()
32//   @MinLength(3)
33//   @MaxLength(50)
34//   name: string;
35//
36//   @IsNumber()
37//   @Min(100)
38//   @Max(10000)
39//   soldiers: number;
40//
41//   @IsEnum(Province)
42//   province: Province;
43// }
44
45// 4. Security Headers (Helmet)
46const securityHeaders = {
47  'X-Content-Type-Options': 'nosniff',
48  'X-Frame-Options': 'DENY',
49  'X-XSS-Protection': '1; mode=block',
50  'Strict-Transport-Security': 'max-age=31536000',
51  'Content-Security-Policy': "default-src 'self'",
52};
53
54// 5. Password Hashing (bcrypt)
55// import * as bcrypt from 'bcrypt';
56// const hash = await bcrypt.hash(password, 10);
57// const isMatch = await bcrypt.compare(password, hash);
58
59// 6. Rate Limiting - DDoS protection
60// @UseGuards(ThrottlerGuard)
61// @Throttle(100, 60)  // 100 requêtes / 60 secondes
62
63// 7. CORS Configuration
64const corsConfig = {
65  origin: ['https://roman-empire.com'],
66  methods: ['GET', 'POST', 'PUT', 'DELETE'],
67  allowedHeaders: ['Content-Type', 'Authorization'],
68  credentials: true,
69};
70
71// 8. Environment Secrets
72// JAMAIS hardcoder :
73// - Mots de passe de la base de données
74// - Clés API
75// - JWT Secret
76// - Clés de chiffrement
77// Use: .env, Docker secrets, Vault, AWS Secrets Manager
78

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. La gestion des secrets en production devrait :

  2. 2. Les bonnes pratiques de sécurité pour les conteneurs Docker :

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Implémente WinstonModule avec les transports : file, error-file et le format JSON

  • Clique dans l'ordre

    Ordonne les étapes de gestion d'un incident de production :