Wzmacnianie bezpieczeństwa Node.js: DoS, iniekcje oraz ochrona kontenerów
Naucz się praktycznych technik ochrony usług Node.js przed atakami typu DoS i ReDoS w pętli zdarzeń, atakami iniekcji oraz niebezpiecznymi kontenerami za pomocą ograniczeń szybkości i narzędzia Helmet.
Nawet backend z doskonałą pokryciem testami i czystą architekturą może zostać wyłączony w mgnieniu oka, jeśli jest podatny na problem zastoju pętli wydarzeń, ataki iniekcji lub słabo zabezpieczony silnik kontenerów.
Ponieważ Node.js działa na jednowątkowej pętli wydarzeń, słabości bezpieczeństwa w usługach Node.js objawiają się nie tylko wyciekami danych, ale także całkowitą niedostępnością — pełnym odmowieniem usług.
1. Obrona pętli wydarzeń: zapobieganie odmowie usług (DoS)
Najpoważniejszym ryzykiem operacyjnym w Node.js jest zablokowanie pętli wydarzeń. Jeśli atakujący dostarczy dane, które zmuszą silnik wyrażeń regularnych do pracy w czasie O(2^n), lub przeładowa serwer nadmiernie dużymi ciałami zapytań HTTP, pętla wydarzeń zostanie zatrzymana. Gdy już się zamarznie, proces przestaje odpowiadać na wszystkie żądania od każdego klienta, a nie tylko od tego złośliwego.
Incoming Request Floods ──┐
▼
┌──────────────────────────┐
│ Node.js Event Loop │
│ (Single Thread Execution)│
└────────────┬─────────────┘
│ ❌ Synchronous CPU Block
▼
┌──────────────────────────┐
│ Event Loop Freeze │ ──► All other concurrent requests
│ (High Latency / DoS) │ time out or drop!
└──────────────────────────┘
Obrona A: Ścisłe limity ciała żądania i ładunku
Jeśli pozostawi się je na wartościach domyślnych, wiele narzędzi do parsowania ciała żądania chętnie przyjmie tak duże ładunki, że wyczerpią dostępną pamięć lub zużyją zbyt wiele cykli procesora tylko na ich parsowanie.
Nieodpowiedni sposób: nieskończone parsowanie ciała
// ❌ BAD Accepts arbitrarily large JSON payloads
app.use(express.json());
Prawidłowy sposób: narzucenie ograniczeń ładunku
Ustaw wyraźne górne granice rozmiaru zarówno w warstwie aplikacji, jak i na poziomie brzegowym – w proxyu odwrotnym lub warstwie wejściowej, takiej jak NGINX, Cloudflare czy AWS Application Load Balancer.
// shared/middleware/security.js
const express = require('express');
// Strict payload limits per content type
const configurePayloadLimits = (app) => {
// Limit standard JSON bodies to 100kb
app.use(express.json({ limit: '100kb' }));
// Limit URL-encoded forms to 50kb
app.use(express.urlencoded({ extended: true, limit: '50kb' }));
};
module.exports = { configurePayloadLimits };
Obrona B: Zapobieganie atakom ReDoS (Denial of Service za pomocą wyrażeń regularnych)
Słabość typu ReDoS powstaje, gdy kod wykonywa wyrażenia regularne z powrotnym przeszukiwaniem wobec danych kontrolowanych przez atakującego.
Schemat podatny na ataki: nawarstwione kwantyfikatory
// ❌ VULNERABLE: Exponential backtracking O(2^n)
const emailRegex = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/;
// An input like "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" will hang the process for minutes!
emailRegex.test(userInput);
Zamiast ręcznie tworzyć wyrażenia regularne do złożonej logiki weryfikacji, skorzystaj z dobrze przetestowanych bibliotek schematów takich jak Zod lub Joi, albo z dedykowanego pakietu do weryfikacji typu validator.
const { z } = require('zod');
// Schema validation using safe built-in string parsers
const UserInputSchema = z.object({
email: z.string().email().max(255), // Enforces linear-time length constraints
username: z.string().alphanumeric().min(3).max(30)
});
function validatePayload(payload) {
return UserInputSchema.parse(payload);
}
2. Zapobieganie atakom iniekcji (SQL, NoSQL i polecenia)
Błędy iniekcji występują wtedy, gdy niezaufane wartości są bezpośrednio wstawiane do zapytań bazodanowych lub do poleceń przekazywanych systemowi operacyjnemu.
Zła metoda: Łączenie ciągów znaków w zapytaniach
// ❌ VULNERABLE: Direct string interpolation (SQL Injection)
async function findUserByEmail(email) {
const query = `SELECT * FROM users WHERE email = '${email}'`;
return await db.query(query); // Attacker passes: "' OR '1'='1"
}
Prawidłowa metoda: Zapytania z parametrami i silne typowanie
Unikaj tworzenia zapytań poprzez wstawianie surowych parametrów bezpośrednio do wywołań drivera bazy danych lub ORM. Używaj zastępczych symboli parametrów — $1, $2 lub ? w zależności od drivera — aby silnik bazy danych zawsze traktował podane wartości jako czyste dane, a nie jako wykonywalny SQL.
// ✅ GOOD: Parameterized SQL Execution
async function findUserByEmail(email) {
const text = 'SELECT id, email, role, created_at FROM users WHERE email = $1';
const values = [email];
const result = await db.query(text, values);
return result.rows[0] || null;
}
3. Dystrybuowane ograniczanie szybkości przez Redis
Ograniczanie szybkości jest niezbędne w przypadku poważnej API. Jeśli go pominiesz, otwierasz drogę dla nadużywających klientów lub niewłaściwie działających integracji do intensywnego obciążania twoich punktów końcowych, co wyczerpuje połączenia bazy danych i pogarsza wydajność dla wszystkich innych.
Problem polega na tym, że w środowiskach z wieloma działającymi instancjami — pomyślmy o kontenerach Kubernetes za load balancerem — ogranicznik szybkości, który przechowuje swoje liczniki w pamięci lokalnej, po prostu nie działa, ponieważ każdy proces ma swój własny izolowany stan. Zamiast tego potrzebna jest wspólna, rozproszona baza danych, tak jak Redis, aby każda instancja mogła sprawdzać i aktualizować ten sam licznik atomowy.
Client Requests ──► [ Pod 1 ] ──┐
├──► [ Distributed Redis ]
Client Requests ──► [ Pod 2 ] ──┘ (Atomic Request Counter)
Implementacja ogranicznika szybkości w środowiskach korporacyjnych
// shared/middleware/rateLimiter.js
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('../database/redis');
// Create rate limiter instance: max 100 requests per 15 minutes per IP
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl_api',
points: 100, // Maximum requests allowed
duration: 15 * 60, // Per 15 minutes (in seconds)
blockDuration: 60 * 5 // Block IP for 5 minutes if exceeded
});
const rateLimiterMiddleware = async (req, res, next) => {
try {
const clientIp = req.ip || req.headers['x-forwarded-for'];
await rateLimiter.consume(clientIp);
next();
} catch (rejRes) {
// Return standard HTTP 429 Too Many Requests
res.setHeader('Retry-After', Math.round(rejRes.msBeforeNext / 1000) || 60);
return res.status(429).json({
status: 'error',
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Please slow down your requests.'
});
}
};
module.exports = rateLimiterMiddleware;
4. Bezpieczeństwo nagłówków HTTP za pomocą Helmet
Domyślnie odpowiedzi Express ujawniają wewnętrzne szczegóły poprzez nagłówki takie jak X-Powered-By: Express. Ten jeden nagłówek daje atakującym szybką drogę: wiedzą dokładnie, jaki framework używasz, i mogą od razu skorzystać z znanych exploitów specyficznych dla tego frameworka.
Rozwiązaniem jest zastosowanie wzmocnionego zestawu standardowych nagłówków odpowiedzi za pomocą middleware’u helmet.
// shared/middleware/securityHeaders.js
const helmet = require('helmet');
function applySecurityHeaders(app) {
app.use(
helmet({
// Hide frame options to mitigate Clickjacking
frameguard: { action: 'deny' },
// Strict Content Security Policy
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: []
}
},
// Remove X-Powered-By header
hidePoweredBy: true,
// Enforce HTTPS
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
}
})
);
}
module.exports = { applySecurityHeaders };
5. Bezpieczeństwo kontenera i środowiska działania: Wzmocnianie plików Dockerfile
Napisanie bezpiecznego kodu aplikacji nie wystarczy, jeśli proces rozruchu go unieważnia. Jeśli proces Node.js działa jako root wewnątrz swojego kontenera Docker, to każda ujawniona luka umożliwiająca wykonywanie kodu zdalnego daje atakującemu dostęp na poziomie root – co może umożliwić dostęp do systemu plików kontenera lub nawet do hosta.
Najlepsze praktyki przy tworzeniu plików Dockerfile do produkcji w kilku etapach
# -------------------------------------------------------------------
# STAGE 1: Build & Dependencies
# -------------------------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
# Copy dependency manifests
COPY package*.json ./
# Install all dependencies (including devDependencies for building/testing)
RUN npm ci
# Copy source code
COPY . .
# Prune devDependencies for production runtime
RUN npm prune --production
# -------------------------------------------------------------------
# STAGE 2: Minimal Production Runtime
# -------------------------------------------------------------------
FROM node:20-alpine AS runner
# Set NODE_ENV to production
ENV NODE_ENV=production
WORKDIR /usr/src/app
# Copy built artifacts and production node_modules from builder
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=builder /usr/src/app/src ./src
# 🔒 SECURITY REQUIREMENT: Never run Node.js as root in container environments
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
Dlaczego to ma znaczenie:
USER node: usuwa uprawnienia root z biegnącego procesu i uruchamia go zamiast tego jako użytkownik bez uprawnień
npm ci: zapewnia powtarzalne instalacje poprzez ścisłe przestrzeganie dokładnych wersji zdefiniowanych w pliku package-lock.jsonLista kontrolna architektury dla części 4
Użyj tej listy kontrolnej, aby przebadać wdrożenie produkcyjne przed jego wysłaniem:
- Ograniczenia ładunku danych: Czy każdy punkt końcowy API jest chroniony ograniczeniem wielkości ładunku HTTP, na przykład za pomocą
express.json({ limit: '100kb' })? - Obrona przed atakami ReDoS: Czy cały niezaufany tekst wprowadzany przez użytkownika jest weryfikowany za pomocą sprawdzonych bibliotek (Zod, Joi) lub logiki weryfikacji bez powrotu do poprzednich stanów, a nie za pomocą niestandardowych wyrażeń regularnych?
- Zapytania parametryzowane: Czy wszystkie wywołania bazy danych przekazują dane wprowadzone przez użytkownika za pośrednictwem parametrów zdefiniowanych wcześniej ($1, $2 lub odpowiedniki), dzięki czemu iniekcje SQL i NoSQL są strukturalnie niemożliwe?
- Dystrybuowany limitowanie szybkości: Czy atomowy ograniczacz szybkości oparty na Redis aktywnie chroni twoje publiczne API przed ruchem typu denial-of-service?
- Heady bezpieczeństwa: Czy
helmetjest skonfigurowany tak, aby usuwać headery identyfikujące framework, wymuszać HSTS oraz blokować wstawianie ram? - Użytkownik kontenera bez uprawnień: Czy twój Dockerfile wyraźnie deklaruje
USER node, aby proces nigdy nie był uruchamiany jako root? - Skanowanie zależności: Czy automatyczne sprawdzania podatności —
npm audit, Snyk, Dependabot lub podobne narzędzia — są włączone do twojego procesu CI/CD?
Literatura pokrewna
- 20 patterni Node.js, które zapobiegają przerwom w serwerach produkcyjnych — Poznaj 20 praktycznych patterni Node.js – od obsługi błędów po stopniowe wyłączanie i zarządzanie zasobami połączeń – które zapobiegają awariom, zanim konieczne stanie się ponowne uruchomienie.
- Podręcznik polecenia Node.js dla rozwoju lokalnego i serwerów produkcyjnych — Praktyczny przewodnik polecenia, który obejmuje zarządzanie wersjami Node.js, narzędzia do zarządzania pakietami, konfigurację środowiska, debugowanie, PM2 oraz wdrażanie systemów Linux bez przerw.