Kurs NestJS · Moduł 9: Deployment i infrastruktura

Security Best Practices - ochrona przed barbarzyńcami

5 min czytania
W tej lekcji6

Strażniku tributów! Na rubieżach Imperium barbarzyńcy rzadko szturmują mury. Próbują kluczy: haseł wykradzionych z innych serwisów, podrobionych tokenów, sekretów, które ktoś zostawił w repozytorium. Konsul Caesar.js chce, żebyś znał najlepsze metody obrony przed takimi atakami.

Authentication & Authorization

Uwierzytelnianie sprawdza, kim jest legionista, a autoryzacja - co mu wolno. Token JWT weryfikuje strategia Passport:

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'], // przyjmuj tylko algorytm, którym podpisujesz
15    });
16  }
17
18  async validate(payload: any) {
19    return { userId: payload.sub, username: payload.username };
20  }
21}

ExtractJwt.fromAuthHeaderAsBearerToken() czyta token z nagłówka Authorization, a ignoreExpiration: false odrzuca wygasłe. getOrThrow() zatrzymuje start aplikacji, gdy sekretu brakuje, zamiast pracować z pustym kluczem. Lista algorithms zamyka drogę podmianie algorytmu: w teście token podpisany HS512 dostał 401. To, co zwróci validate(), trafia do req.user.

Hasła przechowujemy wyłącznie jako skróty:

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 czyta tylko 72 bajty - dłuższe hasło odrzuć, zamiast po cichu je przyciąć
9    if (Buffer.byteLength(password, 'utf8') > 72) {
10      throw new BadRequestException('Hasło przekracza 72 bajty');
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 z kosztem 12 jest celowo wolny, co utrudnia łamanie skrótów. Czyta jednak tylko pierwsze 72 bajty UTF-8: w teście hasło różniące się dopiero po 72. bajcie przeszło weryfikację, stąd strażnik długości. Przy nowym projekcie polecam Argon2id (pakiet argon2), który OWASP stawia na pierwszym miejscu i który tego limitu nie ma.

Input Validation

Dane wejściowe waliduje DTO z dekoratorami 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: długość zamiast reguł składu
15  @MaxLength(64)
16  password: string;
17}

Klasa \p{L} z flagą u akceptuje litery każdego alfabetu, więc „Łukasz Żółtowski” przechodzi - pierwsza wersja z [a-zA-Z] odrzucała polskie imiona. Reguły hasła pochodzą z NIST SP 800-63B-4: co najmniej 15 znaków, gdy hasło jest jedynym składnikiem logowania (8 przy MFA), dopuszczenie co najmniej 64 znaków i sprawdzenie z listą haseł, które wyciekły. Przy bcrypt pamiętaj, że 64 polskie znaki mogą przekroczyć 72 bajty.

Poprzednia wersja lekcji wymuszała skład hasła:

1// Dawna reguła składu hasła - dziś odradzana
2@Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
3  message: 'Password must contain uppercase, lowercase, number and special character'
4})

NIST wprost zabrania dziś takich reguł. Ludzie odpowiadają na nie przewidywalnym „Haslo123!”, a długie, zwykłe zdanie jest trudniejsze do złamania.

Sekrety - klucze do skarbca

Zarządzanie sekretami w produkcji opiera się na trzech zasadach: sekrety są szyfrowane, regularnie rotowane, a dostęp do nich - audytowany. Trzymaj je w menedżerze sekretów, np. HashiCorp Vault albo usłudze chmury; zmienne środowiskowe to tylko sposób dostarczenia do procesu. Sekret nigdy nie trafia do kodu, repozytorium ani obrazu Dockera.

Brak sekretu najlepiej wykryć przy starcie. ConfigModule.forRoot() przyjmuje validationSchema, np. z biblioteki Joi, i sprawdza zmienne, zanim aplikacja wstanie:

1// app.module.ts - walidacja konfiguracji przy starcie
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 {}

Brak DATABASE_URL i za krótki JWT_SECRET kończą start błędem Config validation error z listą wszystkich problemów naraz, a PORT przychodzi już jako liczba. W projekcie ES używaj import Joi from 'joi': forma import * as Joi kompiluje się, ale w teście padła z błędem Joi.string is not a function. Dokumentacja NestJS poleca dziś w nowych projektach bibliotekę Zod, a Joi obsługuje od wersji 18.

Mury wokół aplikacji

Kontenery hartujesz jak w lekcji o Dockerze: użytkownik inny niż root, minimalny obraz bazowy i skanowanie podatności. Przypinaj też wersje obrazów zamiast tagu :latest, który zmienia się bez ostrzeżenia i nie pozwala odtworzyć, co dokładnie działa na produkcji. Przed zbyt dużą liczbą żądań od jednego klienta chroni rate limiting z lekcji o TLS.

Kronika straży

Bez logów nie wykryjesz włamania. Produkcyjne logi zapisuje Winston przez moduł nest-winston:

1// app.module.ts - kronika straży w produkcji
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() przyjmuje te same opcje co winston.createLogger(). Logi są w JSON-ie, pliki rotują codziennie, a błędy mają osobny plik z dłuższą retencją. Żeby logował przez niego także NestJS, w main.ts wywołaj app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)). Nigdy nie zapisuj haseł ani tokenów.

Gdy barbarzyńcy przejdą przez mur

Obsługa incydentu produkcyjnego ma stałe etapy:

  1. detekcja i alert o problemie,
  2. triage i wstępna reakcja,
  3. mitygacja i rozwiązanie problemu,
  4. analiza post-mortem i usprawnienia.

Post-mortem szuka przyczyn w procesie, a nie winnych, bo tylko wtedy ludzie mówią prawdę. Polecam Ci spisać te etapy w runbooku, zanim zdarzy się pierwszy incydent. W następnej lekcji przygotujemy kopie zapasowe na dzień, w którym mur jednak padnie.

Pamiętaj: najlepszy strażnik nie ufa bramie, tylko sprawdza każdy klucz i zapisuje każdą próbę.

Kod do tej lekcji: src/security.ts
1// Security Best Practices - Ochrona Przed Barbarzyńcami
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 - ochrona przed DDoS
60// @UseGuards(ThrottlerGuard)
61// @Throttle(100, 60)  // 100 requestow / 60 sekund
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// NIGDY nie hardcoduj:
73// - Hasla do bazy danych
74// - Klucze API
75// - JWT Secret
76// - Klucze szyfrowania
77// Uzyj: .env, Docker secrets, Vault, AWS Secrets Manager
78

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. Secrets management w produkcji powinno:

  2. 2. Security best practices dla Docker containers:

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

Zadania praktyczne w grze

  • Edytor kodu

    Zaimplementuj WinstonModule z transportami: file, error-file i format JSON

  • Klikanie w kolejności

    Ułóż etapy obsługi incydentu produkcyjnego:

Przydatne artykuły