Cours NestJS · Module 9: Déploiement et infrastructure
Configuration SSL/TLS et HTTPS - le bouclier défensif de l'Empire
Dans cette leçon6
Légionnaire de la cybersécurité ! Le mot de passe d'un légionnaire qui se connecte transite en clair par le Wi-Fi d'un café, et un site étranger envoie discrètement des requêtes en son nom. L'architecte Vitruvius sait que chaque province a besoin de fortifications : comme les murs protégeaient Rome des envahisseurs, TLS et les protections HTTP protègent une application NestJS contre les attaques venues du réseau.
SSL (Secure Sockets Layer) et son successeur TLS (Transport Layer Security) sont des protocoles cryptographiques qui assurent une communication sécurisée. SSL 2.0 et 3.0 sont aujourd'hui interdits car dangereux, et l'on utilise TLS 1.2 et 1.3 : le nom « certificat SSL » est resté par habitude. HTTPS, c'est HTTP qui passe par TLS, lequel chiffre les données entre le client et le serveur. Nous allons bâtir quatre enceintes, du transport à l'application : TLS chiffre la route, Helmet pose les en-têtes de sécurité, CORS décide quels sites peuvent appeler l'API, et le rate limiting compte les requêtes des clients.
Générer des certificats SSL
La première étape consiste à obtenir des certificats. En développement, on peut générer des certificats auto-signés, et en production on passe par Let's Encrypt ou des fournisseurs commerciaux.
Certificats de développement (self-signed)
Le script appelle openssl via execSync et enregistre la clé et le certificat dans le répertoire certs :
1// scripts/generate-dev-cert.ts
2import { execSync } from 'node:child_process';
3import * as fs from 'node:fs';
4import * as path from 'node:path';
5
6// Répertoire calculé depuis le répertoire du projet - les modules ES n'ont pas de __dirname
7const certsDir = path.join(process.cwd(), 'certs');
8
9// Créer le répertoire des certificats
10if (!fs.existsSync(certsDir)) {
11 fs.mkdirSync(certsDir, { recursive: true });
12}
13
14// Générer une clé privée et un certificat avec des noms dans subjectAltName
15execSync(`openssl req -x509 -newkey rsa:4096 \
16 -keyout ${certsDir}/key.pem \
17 -out ${certsDir}/cert.pem \
18 -days 365 -nodes \
19 -subj "/C=PL/ST=Roma/L=Roma/O=Imperium/CN=localhost" \
20 -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"`);
21
22console.log('Certificats de développement générés dans le répertoire certs/');-nodes laisse la clé sans mot de passe (OpenSSL 3 appelle cette option -noenc, mais l'ancien nom fonctionne aussi avec LibreSSL sur macOS). -addext ajoute des noms dans le champ subjectAltName : les navigateurs ignorent le CN seul depuis des années, ils rejetteraient donc un certificat sans ce champ. On calcule le répertoire depuis process.cwd(), car un nouveau projet NestJS 12 est un module ES, où __dirname n'existe pas.
Configuration HTTPS dans NestJS
NestFactory.create() accepte des httpsOptions avec la clé et le certificat :
1// src/main.ts - uruchomienie serwera z HTTPS
2import { NestFactory } from '@nestjs/core';
3import { NestExpressApplication } from '@nestjs/platform-express';
4import { AppModule } from './app.module';
5import * as fs from 'node:fs';
6import * as path from 'node:path';
7
8async function bootstrap() {
9 const isProduction = process.env.NODE_ENV === 'production';
10
11 // Options HTTPS - certificats SSL
12 const httpsOptions = isProduction
13 ? {
14 key: fs.readFileSync(process.env.SSL_KEY_PATH),
15 cert: fs.readFileSync(process.env.SSL_CERT_PATH),
16 ca: fs.readFileSync(process.env.SSL_CA_PATH), // Chaîne de certificats
17 }
18 : {
19 key: fs.readFileSync(path.join(process.cwd(), 'certs', 'key.pem')),
20 cert: fs.readFileSync(path.join(process.cwd(), 'certs', 'cert.pem')),
21 };
22
23 const app = await NestFactory.create<NestExpressApplication>(AppModule, { httpsOptions });
24
25 // Derrière un reverse proxy : faire confiance aux en-têtes X-Forwarded-* du premier intermédiaire
26 app.set('trust proxy', 1);
27
28 // Forcer la redirection HTTP -> HTTPS
29 if (isProduction) {
30 app.use((req, res, next) => {
31 if (!req.secure) {
32 // L'adresse vient de la configuration, pas de l'en-tête Host fixé par le client
33 return res.redirect(301, `https://${process.env.PUBLIC_HOST}${req.originalUrl}`);
34 }
35 next();
36 });
37 }
38
39 await app.listen(process.env.PORT || 3000);
40}
41bootstrap();TLS se termine soit dans Node.js lui-même (httpsOptions), soit dans un reverse proxy qui transmet le trafic en HTTP simple avec un en-tête X-Forwarded-Proto. trust proxy indique à Express de faire confiance à cet en-tête, donc req.secure fonctionne dans les deux modèles. La première version vérifiait l'en-tête seul, et sans proxy il est absent : chaque requête recevait une redirection et une boucle se formait. L'adresse cible vient de la configuration, car l'en-tête Host est fixé par le client.
Helmet.js - en-têtes de sécurité HTTP
Helmet est comme le casque d'un centurion : il protège la tête, c'est-à-dire les en-têtes HTTP, contre les attaques les plus courantes :
1// src/main.ts - configuration de Helmet
2import helmet from 'helmet';
3
4async function bootstrap() {
5 const app = await NestFactory.create(AppModule);
6
7 // Configuration de base de Helmet - suffit pour la plupart des API :
8 // app.use(helmet());
9
10 // Configuration avancée de Helmet (au lieu de la configuration de base)
11 app.use(helmet({
12 // Content-Security-Policy - contrôle d'où se chargent les ressources
13 contentSecurityPolicy: {
14 directives: {
15 defaultSrc: ["'self'"],
16 scriptSrc: ["'self'"],
17 styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
18 fontSrc: ["'self'", 'https://fonts.gstatic.com'],
19 imgSrc: ["'self'", 'data:', 'https:'],
20 connectSrc: ["'self'", ...(process.env.API_URL ? [process.env.API_URL] : [])],
21 frameSrc: ["'none'"],
22 objectSrc: ["'none'"],
23 },
24 },
25 // Strict-Transport-Security - impose HTTPS
26 strictTransportSecurity: {
27 maxAge: 31536000, // 1 an
28 includeSubDomains: true,
29 preload: true,
30 },
31 // X-Frame-Options - empêche le clickjacking
32 xFrameOptions: { action: 'deny' },
33 // X-Content-Type-Options - empêche le MIME sniffing
34 xContentTypeOptions: true,
35 // Referrer-Policy
36 referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
37 }));
38}Les noms d'options viennent de Helmet 8 ; les anciens hsts, frameguard et noSniff sont des alias obsolètes. 'unsafe-inline' a disparu de scriptSrc, car il désactivait la protection de la CSP contre les XSS. preload inscrit le domaine sur la liste HSTS des navigateurs, ce qui est difficile à annuler, active-le donc en connaissance de cause. Depuis NestJS 12.1, les mêmes en-têtes par défaut sont posés par la méthode intégrée app.useSecurityHeaders().
CORS pour la production
CORS (Cross-Origin Resource Sharing) contrôle quels domaines peuvent communiquer avec notre API. C'est comme la liste des ambassadeurs de confiance de l'Empire :
1// src/main.ts - configuration CORS de production
2import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
3
4const corsOptions: CorsOptions = {
5 origin: (origin, callback) => {
6 const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [];
7
8 // Autoriser les requêtes sans origin (mobile, Postman)
9 if (!origin) return callback(null, true);
10
11 if (allowedOrigins.includes(origin)) {
12 callback(null, true);
13 } else {
14 // false = pas d'en-têtes CORS ; le navigateur bloque lui-même la réponse
15 callback(null, false);
16 }
17 },
18 methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
19 allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
20 exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
21 credentials: true,
22 maxAge: 86400, // Cache du preflight pendant 24h (Chromium le limite à 2h)
23};
24
25app.enableCors(corsOptions);Un domaine étranger ne reçoit tout simplement aucun en-tête CORS ; la première version renvoyait une erreur, ce qui se terminait par une réponse 500 et des déchets dans les logs. CORS ne protège que les utilisateurs de navigateurs : Postman ou une application mobile l'ignorent, il ne remplace donc pas l'autorisation.
Rate Limiting avec @nestjs/throttler
Le rate limiting, ce sont les gardes aux portes qui comptent combien de personnes entrent en un temps donné :
1// src/app.module.ts - configuration de ThrottlerModule
2import { Module } from '@nestjs/common';
3import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
4import { APP_GUARD } from '@nestjs/core';
5
6@Module({
7 imports: [
8 ThrottlerModule.forRoot([
9 {
10 name: 'short', // Limite à court terme
11 ttl: 1000, // 1 seconde
12 limit: 3, // Max 3 requêtes par seconde
13 },
14 {
15 name: 'medium', // Limite moyenne
16 ttl: 10000, // 10 secondes
17 limit: 20, // Max 20 requêtes par 10 secondes
18 },
19 {
20 name: 'long', // Limite à long terme
21 ttl: 60000, // 1 minute
22 limit: 100, // Max 100 requêtes par minute
23 },
24 ]),
25 ],
26 providers: [
27 {
28 provide: APP_GUARD,
29 useClass: ThrottlerGuard,
30 },
31 ],
32})
33export class AppModule {}ttl s'exprime en millisecondes, et les trois limites s'appliquent en même temps. Le throttler protège contre le brute force et les abus de l'API, mais pas contre un vrai DDoS, qui sature la liaison avant que le trafic n'atteigne Node.js. Derrière un proxy, active trust proxy, et avec plusieurs instances, utilise un stockage partagé dans Redis.
Les décorateurs modifient les limites pour des endpoints précis :
1// Désactiver le rate limiting pour certains endpoints
2import { SkipThrottle, Throttle } from '@nestjs/throttler';
3
4@Controller('legions')
5export class LegionsController {
6 @SkipThrottle({ short: true, medium: true, long: true }) // Pas de limite pour le health check
7 @Get('health')
8 healthCheck() {
9 return { status: 'ok' };
10 }
11
12 @Throttle({ short: { ttl: 1000, limit: 1 } }) // Une limite plus stricte
13 @Post('login')
14 login() {
15 // Connexion limitée à 1 req/s
16 }
17}Avec des limites nommées, @SkipThrottle() sans argument n'en désactive aucune : dans le test, le health check a reçu un 429 après trois requêtes, c'est pourquoi on indique les noms. @Throttle ne change que short ; medium et long s'appliquent toujours.
Protection CSRF
Le CSRF (Cross-Site Request Forgery) est une attaque dans laquelle un site malveillant effectue des requêtes au nom d'un utilisateur connecté. Il concerne la connexion par cookies ; le navigateur n'ajoute pas de lui-même un JWT dans l'en-tête Authorization. Les anciens projets se protégeaient avec le paquet csurf :
1// src/main.ts - protection CSRF avec csurf (paquet obsolète, seulement pour les anciens projets)
2import csurf from 'csurf';
3import cookieParser from 'cookie-parser';
4
5async function bootstrap() {
6 const app = await NestFactory.create(AppModule);
7
8 // csurf avec l'option cookie exige cookie-parser avant lui
9 app.use(cookieParser());
10
11 // CSRF protection (pour les applications avec session/cookies)
12 app.use(csurf({
13 cookie: {
14 httpOnly: true,
15 secure: process.env.NODE_ENV === 'production',
16 sameSite: 'strict',
17 },
18 }));
19
20 // Middleware qui place le jeton CSRF dans la réponse
21 app.use((req, res, next) => {
22 res.cookie('XSRF-TOKEN', req.csrfToken(), {
23 httpOnly: false, // Le frontend doit pouvoir le lire
24 secure: process.env.NODE_ENV === 'production',
25 sameSite: 'strict',
26 });
27 next();
28 });
29}csurf est archivé et marqué comme obsolète. Il exige aussi cookie-parser avant lui : sans cela, chaque requête du test se terminait par une erreur 500. NestJS 12.1 propose une protection intégrée :
1// src/main.ts - protection CSRF intégrée (NestJS 12.1+)
2async function bootstrap() {
3 const app = await NestFactory.create(AppModule);
4
5 app.enableCsrfProtection({
6 // Un frontend sur un autre domaine qui peut envoyer des POST avec des cookies
7 trustedOrigins: ['https://admin.imperium.rome'],
8 });
9
10 await app.listen(3000);
11}enableCsrfProtection() vérifie l'en-tête Sec-Fetch-Site que les navigateurs ajoutent aux requêtes. Dans le test, un POST venant d'un site étranger a reçu un 403, tandis qu'un GET, les requêtes sans en-têtes de navigateur et les domaines de confiance sont passés. La documentation de NestJS réserve les jetons, par ex. du paquet csrf-csrf, aux très vieux navigateurs.
Configuration de sécurité complète
Le middleware de Helmet peut aussi se brancher dans un module :
1// src/security/security.module.ts
2import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
3import { ThrottlerModule } from '@nestjs/throttler';
4import helmet from 'helmet';
5
6@Module({
7 imports: [
8 ThrottlerModule.forRoot([
9 { name: 'short', ttl: 1000, limit: 3 },
10 { name: 'long', ttl: 60000, limit: 100 },
11 ]),
12 ],
13})
14export class SecurityModule implements NestModule {
15 configure(consumer: MiddlewareConsumer) {
16 consumer
17 .apply(
18 helmet(),
19 helmet.contentSecurityPolicy({
20 directives: {
21 defaultSrc: ["'self'"],
22 scriptSrc: ["'self'"],
23 },
24 }),
25 )
26 .forRoutes('*');
27 }
28}helmet() pose déjà une CSP par défaut, et le deuxième appel l'écrase : un seul appel avec l'option contentSecurityPolicy suffirait.
La sécurité n'est pas une tâche ponctuelle mais un processus continu, comme la défense des murs qui exigeait une vigilance constante des gardes. Chaque couche (TLS, Helmet, CORS, rate limiting, CSRF) ajoute un mur de plus. Je te recommande de partir des réglages par défaut de Helmet et de n'ajouter des exceptions que lorsque quelque chose cesse de fonctionner. Dans la prochaine leçon, nous construirons un pipeline CI/CD qui vérifiera ces murs à chaque déploiement.
Retiens : un seul mur ne suffit jamais - l'Empire se défend par enceintes successives, de la route jusqu'à la porte.
Code de cette leçon : src/security-config.ts
1// Configuration SSL/TLS et HTTPS - le bouclier défensif de l'Empire
2import { NestFactory } from '@nestjs/core';
3import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
4import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
5import { APP_GUARD } from '@nestjs/core';
6import helmet from 'helmet';
7import * as fs from 'fs';
8
9// 1. HTTPS Configuration
10async function bootstrap() {
11 const httpsOptions = {
12 key: fs.readFileSync(process.env.SSL_KEY_PATH),
13 cert: fs.readFileSync(process.env.SSL_CERT_PATH),
14 };
15
16 // TODO : Créer l'application avec httpsOptions
17 // const app = await NestFactory.create(AppModule, { httpsOptions });
18
19 // 2. Helmet - en-têtes de sécurité
20 // app.use(helmet({
21 // contentSecurityPolicy: {
22 // directives: {
23 // defaultSrc: ["'self'"],
24 // scriptSrc: ["'self'"],
25 // },
26 // },
27 // hsts: { maxAge: 31536000, includeSubDomains: true },
28 // frameguard: { action: 'deny' },
29 // }));
30
31 // 3. CORS pour la production
32 // app.enableCors({
33 // origin: process.env.CORS_ORIGINS?.split(','),
34 // methods: ['GET', 'POST', 'PUT', 'DELETE'],
35 // credentials: true,
36 // });
37}
38
39// 4. Rate Limiting z @nestjs/throttler
40@Module({
41 imports: [
42 ThrottlerModule.forRoot([
43 { name: 'short', ttl: 1000, limit: 3 },
44 { name: 'long', ttl: 60000, limit: 100 },
45 ]),
46 ],
47 providers: [
48 { provide: APP_GUARD, useClass: ThrottlerGuard },
49 ],
50})
51export class SecurityModule {}
52
53// TODO : Ajouter le middleware Helmet dans SecurityModule
54// TODO : Configurer CORS avec une liste de domaines autorisés
55// TODO : Ajouter une protection CSRF pour les endpoints avec cookies
56Tu 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. Helmet.js dans une application NestJS sert à :
2. @nestjs/throttler dans NestJS sert à :
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 SecurityModule avec ThrottlerModule, le middleware Helmet et une configuration CORS
- Tri vertical
Ordonne les couches de sécurité d'une application NestJS, de la plus basse (transport) à la plus haute (application) :