Kurs NestJS · Moduł 9: Deployment i infrastruktura
Security Best Practices - ochrona przed barbarzyńcami
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:
- detekcja i alert o problemie,
- triage i wstępna reakcja,
- mitygacja i rozwiązanie problemu,
- 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
78Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Secrets management w produkcji powinno:
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: