Kurs NestJS · Moduł 9: Deployment i infrastruktura

SSL/TLS i HTTPS Configuration - tarcza obronna Imperium

8 min czytania
W tej lekcji6

Legionisto cyberbezpieczeństwa! Hasło logującego się legionisty leci przez kawiarniane Wi-Fi jawnym tekstem, a obca strona po cichu wysyła żądania w jego imieniu. Architekt Vitruvius wie, że każda prowincja potrzebuje fortyfikacji: jak mury chroniły Rzym przed najeźdźcami, tak TLS i zabezpieczenia HTTP chronią aplikację NestJS przed atakami z sieci.

SSL (Secure Sockets Layer) i jego następca TLS (Transport Layer Security) to protokoły kryptograficzne zapewniające bezpieczną komunikację. SSL 2.0 i 3.0 są dziś zakazane jako niebezpieczne, a używa się TLS 1.2 i 1.3 - nazwa „certyfikat SSL” została z przyzwyczajenia. HTTPS to HTTP działający przez TLS, który szyfruje dane między klientem a serwerem. Zbudujemy cztery pierścienie murów, od transportu do aplikacji: TLS szyfruje drogę, Helmet ustawia nagłówki bezpieczeństwa, CORS decyduje, które strony mogą wołać API, a rate limiting liczy żądania klientów.

Generowanie certyfikatów SSL

Pierwszym krokiem jest pozyskanie certyfikatów. W środowisku deweloperskim możemy wygenerować certyfikaty samopodpisane, a na produkcji korzystamy z Let's Encrypt lub komercyjnych dostawców.

Certyfikaty deweloperskie (self-signed)

Skrypt wywołuje openssl przez execSync i zapisuje klucz oraz certyfikat w katalogu 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// Katalog liczony od katalogu projektu - w modułach ES nie ma __dirname
7const certsDir = path.join(process.cwd(), 'certs');
8
9// Tworzenie katalogu na certyfikaty
10if (!fs.existsSync(certsDir)) {
11  fs.mkdirSync(certsDir, { recursive: true });
12}
13
14// Generowanie klucza prywatnego i certyfikatu z nazwami w 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('Certyfikaty deweloperskie wygenerowane w katalogu certs/');

-nodes zostawia klucz bez hasła (OpenSSL 3 nazywa tę opcję -noenc, ale stara nazwa działa też w LibreSSL na macOS). -addext dopisuje nazwy w polu subjectAltName: przeglądarki od lat ignorują samo CN, więc certyfikat bez niego odrzucą. Katalog liczymy od process.cwd(), bo nowy projekt NestJS 12 jest modułem ES, w którym __dirname nie istnieje.

Konfiguracja HTTPS w NestJS

NestFactory.create() przyjmuje httpsOptions z kluczem i certyfikatem:

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  // Opcje HTTPS - certyfikaty 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), // Chain certyfikatów
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  // Za reverse proxy: ufaj nagłówkom X-Forwarded-* od pierwszego pośrednika
26  app.set('trust proxy', 1);
27
28  // Wymuszenie przekierowania HTTP -> HTTPS
29  if (isProduction) {
30    app.use((req, res, next) => {
31      if (!req.secure) {
32        // Adres z konfiguracji, a nie z nagłówka Host, który ustawia klient
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 kończy się albo w samym Node.js (httpsOptions), albo w reverse proxy, które przekazuje ruch zwykłym HTTP z nagłówkiem X-Forwarded-Proto. trust proxy każe Expressowi ufać temu nagłówkowi, więc req.secure działa w obu modelach. Pierwsza wersja sprawdzała sam nagłówek, a bez proxy go nie ma - każde żądanie dostawało przekierowanie i powstawała pętla. Adres docelowy bierzemy z konfiguracji, bo nagłówek Host ustawia klient.

Helmet.js - nagłówki bezpieczeństwa HTTP

Helmet to jak hełm centuriona - chroni głowę, czyli nagłówki HTTP, przed najczęstszymi atakami:

1// src/main.ts - konfiguracja Helmet
2import helmet from 'helmet';
3
4async function bootstrap() {
5  const app = await NestFactory.create(AppModule);
6
7  // Podstawowa konfiguracja Helmet - wystarcza większości API:
8  // app.use(helmet());
9
10  // Zaawansowana konfiguracja Helmet (zamiast podstawowej)
11  app.use(helmet({
12    // Content-Security-Policy - kontroluje skąd ładowane są zasoby
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 - wymusza HTTPS
26    strictTransportSecurity: {
27      maxAge: 31536000, // 1 rok
28      includeSubDomains: true,
29      preload: true,
30    },
31    // X-Frame-Options - zapobiega clickjacking
32    xFrameOptions: { action: 'deny' },
33    // X-Content-Type-Options - zapobiega MIME sniffing
34    xContentTypeOptions: true,
35    // Referrer-Policy
36    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
37  }));
38}

Nazwy opcji pochodzą z Helmet 8; dawne hsts, frameguard i noSniff to przestarzałe aliasy. Z scriptSrc zniknęło 'unsafe-inline', które wyłączało ochronę CSP przed XSS. preload wpisuje domenę na listę HSTS w przeglądarkach i trudno to cofnąć, więc włączaj go świadomie. Od NestJS 12.1 te same domyślne nagłówki ustawia wbudowane app.useSecurityHeaders().

CORS dla produkcji

CORS (Cross-Origin Resource Sharing) kontroluje, które domeny mogą komunikować się z naszym API. To jak lista zaufanych ambasadorów Imperium:

1// src/main.ts - produkcyjna konfiguracja CORS
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    // Pozwól na requesty bez origin (mobile, Postman)
9    if (!origin) return callback(null, true);
10
11    if (allowedOrigins.includes(origin)) {
12      callback(null, true);
13    } else {
14      // false = brak nagłówków CORS; przeglądarka sama zablokuje odpowiedź
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 preflight na 24h (Chromium skraca do 2h)
23};
24
25app.enableCors(corsOptions);

Obca domena dostaje po prostu brak nagłówków CORS; pierwsza wersja zwracała błąd, co kończyło się odpowiedzią 500 i śmieciami w logach. CORS chroni tylko użytkowników przeglądarek - Postman czy aplikacja mobilna go ignorują, więc nie zastąpi autoryzacji.

Rate Limiting z @nestjs/throttler

Rate limiting to strażnicy przy bramach, którzy liczą, ile osób wchodzi w danym czasie:

1// src/app.module.ts - konfiguracja 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',  // Krótkoterminowy limit
11        ttl: 1000,      // 1 sekunda
12        limit: 3,       // Max 3 requesty na sekundę
13      },
14      {
15        name: 'medium', // Średni limit
16        ttl: 10000,     // 10 sekund
17        limit: 20,      // Max 20 requestów na 10 sekund
18      },
19      {
20        name: 'long',   // Długoterminowy limit
21        ttl: 60000,     // 1 minuta
22        limit: 100,     // Max 100 requestów na minutę
23      },
24    ]),
25  ],
26  providers: [
27    {
28      provide: APP_GUARD,
29      useClass: ThrottlerGuard,
30    },
31  ],
32})
33export class AppModule {}

ttl podajemy w milisekundach, a wszystkie trzy limity obowiązują jednocześnie. Throttler chroni przed brute force i nadużyciami API, ale nie przed prawdziwym DDoS, który zatyka łącze, zanim ruch dotrze do Node.js. Za proxy ustaw trust proxy, a przy kilku instancjach użyj wspólnego magazynu w Redisie.

Dekoratory zmieniają limity dla pojedynczych endpointów:

1// Wyłączenie rate limitingu dla konkretnych endpointów
2import { SkipThrottle, Throttle } from '@nestjs/throttler';
3
4@Controller('legions')
5export class LegionsController {
6  @SkipThrottle({ short: true, medium: true, long: true }) // Bez limitu dla health check
7  @Get('health')
8  healthCheck() {
9    return { status: 'ok' };
10  }
11
12  @Throttle({ short: { ttl: 1000, limit: 1 } }) // Surowszy limit
13  @Post('login')
14  login() {
15    // Logowanie z ograniczeniem 1 req/s
16  }
17}

Przy nazwanych limitach @SkipThrottle() bez argumentów nie wyłącza żadnego z nich - w teście health check dostał 429 po trzech żądaniach, dlatego podajemy nazwy. @Throttle zmienia tylko short; medium i long nadal działają.

Ochrona CSRF

CSRF (Cross-Site Request Forgery) to atak, w którym złośliwa strona wykonuje żądania w imieniu zalogowanego użytkownika. Dotyczy logowania ciasteczkami; token JWT w nagłówku Authorization przeglądarka sama nie dołącza. Starsze projekty chroniły się pakietem csurf:

1// src/main.ts - ochrona CSRF z csurf (pakiet przestarzały, tylko dla starych projektów)
2import csurf from 'csurf';
3import cookieParser from 'cookie-parser';
4
5async function bootstrap() {
6  const app = await NestFactory.create(AppModule);
7
8  // csurf z opcją cookie wymaga wcześniej cookie-parser
9  app.use(cookieParser());
10
11  // CSRF protection (dla aplikacji z sesją/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 ustawiający CSRF token w odpowiedzi
21  app.use((req, res, next) => {
22    res.cookie('XSRF-TOKEN', req.csrfToken(), {
23      httpOnly: false, // Frontend musi móc odczytać
24      secure: process.env.NODE_ENV === 'production',
25      sameSite: 'strict',
26    });
27    next();
28  });
29}

csurf jest zarchiwizowany i oznaczony jako przestarzały. Wymaga też cookie-parser przed sobą - bez niego w teście każde żądanie kończyło się błędem 500. NestJS 12.1 ma ochronę wbudowaną:

1// src/main.ts - wbudowana ochrona CSRF (NestJS 12.1+)
2async function bootstrap() {
3  const app = await NestFactory.create(AppModule);
4
5  app.enableCsrfProtection({
6    // Frontend z innej domeny, który może wysyłać POST-y z ciasteczkami
7    trustedOrigins: ['https://admin.imperium.rome'],
8  });
9
10  await app.listen(3000);
11}

enableCsrfProtection() sprawdza nagłówek Sec-Fetch-Site, który przeglądarki dołączają do żądań. W teście POST z obcej strony dostał 403, a GET, żądania bez nagłówków przeglądarki i zaufane domeny przeszły. Tokeny, np. z pakietu csrf-csrf, dokumentacja NestJS zostawia dla bardzo starych przeglądarek.

Kompletna konfiguracja bezpieczeństwa

Middleware z Helmet można też podpiąć w 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() ustawia już domyślne CSP, a drugie wywołanie je nadpisuje - wystarczyłoby jedno z opcją contentSecurityPolicy.

Bezpieczeństwo to nie jednorazowe zadanie, a ciągły proces - jak obrona murów, która wymagała stałej czujności strażników. Każda warstwa (TLS, Helmet, CORS, rate limiting, CSRF) dodaje kolejny mur. Polecam Ci zaczynać od domyślnych ustawień Helmet i dokładać wyjątki dopiero wtedy, gdy coś przestaje działać. W następnej lekcji zbudujemy pipeline CI/CD, który sprawdzi te mury przy każdym wdrożeniu.

Pamiętaj: jeden mur nigdy nie wystarcza - Imperium broni się pierścieniami, od drogi po bramę.

Kod do tej lekcji: src/security-config.ts
1// SSL/TLS i HTTPS Configuration - Tarcza Obronna Imperium
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: Utworz aplikacje z httpsOptions
17  // const app = await NestFactory.create(AppModule, { httpsOptions });
18
19  // 2. Helmet - naglowki bezpieczenstwa
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 dla produkcji
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: Dodaj Helmet middleware w SecurityModule
54// TODO: Skonfiguruj CORS z lista dozwolonych domen
55// TODO: Dodaj CSRF protection dla endpointow z cookies
56

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Helmet.js w aplikacji NestJS służy do:

  2. 2. @nestjs/throttler w NestJS służy do:

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Zaimplementuj SecurityModule z ThrottlerModule, Helmet middleware i konfiguracją CORS dla produkcji

  • Układanie w pionie

    Uporządkuj warstwy zabezpieczeń aplikacji NestJS od najniższej (transport) do najwyższej (aplikacja):

Przydatne artykuły