Zabezpieczanie API w Express: autoryzacja, walidacja, ograniczenia częstotliwości żądań oraz monitorowanie.
Praktyczny przewodnik po zabezpieczeniach API w Express: autoryzacja za pomocą Passport i JWT, modele uprawnień, szyfrowanie AES-GCM, walidacja, ograniczanie częstotliwości żądań oraz logowanie.
Każda API, którą udostępniasz, stanowi bramę do twojego systemu, a atakujący badają te bramy znacznie bardziej systematycznie niż większość zespołów je testuje. Bezpieczeństwo dodawane później, po uruchomieniu, często pozostawia luki: trasę bez ochrony, zapytanie skonstruowane na podstawie surowych danych wejściowych, punkt logowania gotowy przyjąć milion prób. Ten przewodnik przedstawia standardy, które warto znać, najczęściej występujące zagrożenia oraz sześć konkretnych warstw obrony wdrożonych w Node.js z Express, abyś mógł przeprowadzić audyt istniejącej API lub stworzyć nową z ochroną od samego początku.
Traktuj poniższe informacje jako listę kontrolną, do której będziesz wracać przez cały cykl życia rozwoju: przed wydaniem nowej wersji, po zastosowaniu poprawki oraz za każdym razem, gdy zmienia się jakaś zależność lub trasa. Regularne przeprowadzanie tych sprawdzeń pozwala wykrywać słabości, gdy są jeszcze niewielkie.
Dlaczego API wymagają specjalnej uwagi pod względem bezpieczeństwa
Współczesne produkty są coraz częściej tworzone przy użyciu interfejsów API. Zamiast rozwijać każdą funkcjonalność we własnym zakresie, zespoły integrują usługi płatności, identyfikacji, komunikacji i przechowywania danych za pośrednictwem dobrze zdefiniowanych interfejsów, a wiele firm dostarcza obecnie produkty oparte przede wszystkim na API. Jedno z badań rynkowych szacuje wartość gospodarki opartej na API na około 20 miliardów dolarów w 2026 roku (streszczenie raportu); niezależnie od dokładnej kwoty, ta zależność jest rzeczywista i rośnie.
To zaufanie ma charakter dwukierunkowy. API zapewnia prawomocnym klientom gotowe, wielokrotnie używalne funkcje, ale jednocześnie daje atakującym udokumentowany i łatwy do wykorzystania punkt wejścia. Badania branżowe regularnie łączą dużą liczbę incydentów bezpieczeństwa z interfejsami API; raport Traceable z 2023 roku przypisuje im 74% incydentów wycieku danych.
API często znajdują się bezpośrednio przy najbardziej wrażliwych danych, które posiada firma: platformach identyfikacyjnych z danymi osobowymi, dokumentacji finansowej oraz wewnętrznych procesach pracy. Nieuprawniony dostęp może skutkować uszkodzonymi danymi, nadużyciem usług, stratami finansowymi, utratą zaufania klientów oraz karami regulacyjnymi zgodnie z ustawą o ochronie danych. Ze względu na tak wysokie konsekwencje, bezpieczeństwo powinno być uwzględniane w umowach na poziomie usług obok parametrów dotyczących czasu dostępności, a jest to odpowiedzialność każdego zespołu produktowego, a nie tylko dedykowanej grupy ds. bezpieczeństwa. Rozsądnym punktem wyjścia są standardy, które branża już uzgodniła.
Standardy i ramy, które warto znać
Standardy bezpieczeństwa API to formalne specyfikacje, protokoły i wytyczne stosowane w całym cyklu życia rozwoju oprogramowania, aby ochrona była spójna, a nie improwizowana. Najczęściej spotykane to:
- OWASP API Security Top 10: uporządkowana lista najważniejszych zagrożeń dla API, utrzymywana przez Open Web Application Security Project. Obejmuje takie problemy jak błędna autoryzacja na poziomie obiektu, sfałszowanie żądań po stronie serwera oraz nieograniczone zużycie zasobów; stanowi najlepszy punkt wyjścia do przeglądu zagrożeń.
- OAuth 2.0 i 2.1: framework do upoważniania delegowanego. Klient otrzymuje token dostępu z określonymi zakresami i używa go do działania w imieniu użytkownika, bez konieczności przechowywania jego hasła; tokeny odnowienia umożliwiają klientowi uzyskanie nowych tokenów dostępu bez ponownego proszenia użytkownika.
- OpenID Connect (OIDC): warstwa tożsamości oparta na OAuth. Standaryzuje token identyfikacyjny oraz sposób, w jaki klienci go walidują, co umożliwia współpracę między różnymi dostawcami usług tożsamości przy logowaniu jednorazowym i pobieraniu profilu.
Są to solidne podstawy, choć nie stanowią wykazu wszystkich rozwiązań.
Zagrożenia, przed którymi się bronicie
Najszybciej występujące w rzeczywistych API słabości to:
- Złamana autoryzacja: słabe lub brakujące kontrole tożsamości oraz niewłaściwe zarządzanie sesjami umożliwiają atakownikowi kradzież plików cookie lub tokenów oraz ich ponowne użycie w celu dostępu do Twoich usług.
- Złamana autorizacja na poziomie obiektu (BOLA): API sprawdza, czy użytkownik jest zalogowany, ale nie sprawdza, czy ma uprawnienia do edycji konkretnego rekordu, więc zmiana ID w adrese URL ujawnia dane innej osoby lub wewnętrzne procesy.
- Injekcja SQL: dane kontrolowane przez atakownika są łączone z zapytaniem i wykonywane przez bazę danych. Prawie każdy klient bazy danych oferuje mechanizm parametrów, który umożliwia bezpieczne przekazywanie wartości.
- Injekcja poleceń: niezaufane dane z żądania trafiają do wiersza poleceń systemu lub procesora poleceń, co pozwala atakownikowi uruchamiać dowolne polecenia z uprawnieniami procesu serwera.
Każda z tych kwestii ma odpowiadającą jej praktykę programistyczną. Reszta tego przewodnika omawia je w sześciu poziomach, używając przez cały czas JavaScript i Express.
1. Autoryzacja: udowodnienie tożsamości osoby żądającej dostępu
Każda chroniona API powinna wymagać od klienta udowodnienia jego tożsamości przed wykonywaniem jakichkolwiek istotnych działań, czy to poprzez nazwę użytkownika i hasło, klucz API lub podpisany token. Metody autoryzacji dzielą się generalnie na pięć kategorii: autoryzacja podstawiona na nazwie użytkownika i hasło, autoryzacja wielofaktorowa, autoryzacja oparta na tokenach, autoryzacja oparta na certyfikatach oraz biometria.
Częstym źródłem zamieszania jest to, co tak naprawdę zastępuje JWT. Tradycyjne sesje serwerowe przechowywały stan na serwerze i polegały na plikach cookie w przeglądarce do przenoszenia identyfikatora sesji. JWT eliminuje konieczność takiego wyszukiwania po stronie serwera przy każdej prośbie, ale sam nie weryfikuje danych uwierzytelniających – ktoś nadal musi sprawdzić hasło raz przed wydaniem tokena. Dlatego dobrze sprawdza się model hybrydowy: użytkownik loguje się za pomocą adresu e-mail i hasła, serwer wydaje JWT w przypadku sukcesu, a każda późniejsza prośba zawiera jedynie token. Weryfikacja danych uwierzytelniających i autoryzacja przy każdej prośbie stają się odrębnymi kwestiami, a hasło nie jest już przekazywane przy każdym wezwaniu.
W Express biblioteka Passport obsługuje obie te metody za pomocą strategii, które można łatwo dodać. Konfiguracja wymaga trzech kroków.
Krok 1: zarejestrować strategię lokalną oraz strategię JWT
Strategia lokalna jest wykonywana raz, podczas logowania, i ma za zadanie wyszukać użytkownika według adresu e-mail oraz porównać podaną hasło z zapisanym hashem. Strategia JWT jest uruchamiana przy każdym zapytaniu chronionym: pobiera token z nagłówka Authorization: Bearer, weryfikuje podpis na podstawie JWT_SECRET oraz identyfikuje użytkownika wskazanego w treści zapytania. Eksport uprzednio skonfigurowanego middleware’u authenticateJWT z ustawieniem session: false wyraźnie wskazuje na brak przechowywania stanu.
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
// Local Strategy: Verify username and password during login.
passport.use(
new LocalStrategy(
{ usernameField: "email", passwordField: "password" },
async (email, password, done) => {
// Find the user and compare the hashed password.
// If valid, return the user.
}
)
);
// JWT Strategy: Verify the token on protected requests.
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
// Find the user referenced in the token.
}
)
);
// Middleware
const authenticateJWT = passport.authenticate("jwt", { session: false, });
module.exports = { passport, authenticateJWT, };
Wtyczki weryfikacyjne są tutaj umieszczone jako komentarze, a właśnie tam odbywa się rzeczywista praca związana z bezpieczeństwem. Do porównywania haseł należy używać wolnego algorytmu haszowania z solą, takiego jak bcrypt lub Argon2, a w przypadku błędu należy zwracać ten sam ogólny komunikat niezależnie od tego, czy adres e-mail jest nieznany, czy hasło błędne, aby endpoint nie mógł być wykorzystywany do odkrycia istniejących kont.
Krok 2: uwierzytelnij się podczas logowania i wygeneruj token
Obsługa logowania wywołuje lokalną strategię za pomocą dostosowanego callbacka. Błąd trafia do mechanizmu obsługi błędów Express, brakujący użytkownik powoduje błąd 401, a udane dopasowanie generuje token podpisany identyfikatorem i rolą użytkownika oraz datą ważności zmienną JWT_EXPIRES_IN, przy czym domyślnie jest to dwa godziny.
const jwt = require("jsonwebtoken");
const passport = require("passport");
const login = (req, res, next) => {
passport.authenticate("local", { session: false }, (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ message: info.message, });
// Issue a signed JWT after successful authentication.
const token = jwt.sign(
{ id: user.id, role: user.role,},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || "2h",}
);
return res.status(200).json({ message: "Login successful.", token, user,});
})(req, res, next);
};
Dwa aspekty wymagają uwagi. Krótki okres ważności określa, jak długo skradziony token pozostaje użyteczny; jeśli sesje muszą trwać dłużej, należy łączyć krótkie tokeny dostępu z mechanizmem odświeżania, takim jak ten opisany w naszym strategii tokenów odświeżania dla systemów autoryzacji w Node.js. Ponadto odpowiedź zwraca obiekt user w niezmienionej formie. Jeśli jest to surowy rekord z bazy danych, może zawierać hasło w formie hashu oraz dane wewnętrzne, co stanowi dokładnie ten nadmierny ujawnianie danych opisany wcześniej. Zamiast tego należy zwrócić wyraźny podzbiór danych, np. ID, adres e-mail i rolę.
Krok 3: ochrona tras chronionych
Po eksporcie middleware’u ochrona trasy polega na umieszczeniu funkcji authenticateJWT przed kontrolerem w definicji trasy. Prośby bez ważnego tokena są odrzucane przed uruchomieniem jakiejkolwiek logiki biznesowej.
const { Router } = require("express");
const authRouter = Router();
// Get auth middleware and sample prorected controller
const authController = require("../controllers/auth.controller");
const { authenticateJWT } = require("../middleware/authentication");
// Use JWT as a guard to protect certain routes
authRouter.get("/me", authenticateJWT, authController.me);
authRouter.patch("/password", authenticateJWT, authController.updatePassword);
Jeśli wolisz lżejsze routery, tę samą ochronę można zamiast tego włączyć do łańcucha middleware samego kontrolera. W obu przypadkach ustaw ochronę jako standardową dla routera i celowo wyłącz drogi publiczne, zamiast pamiętać o dodawaniu ochrony dla każdej drogi osobno.
2. Uprawnienia: decydowanie o tym, co może robić użytkownik
Autoryzacja odpowiada na pytanie „kim jesteś?“, natomiast uprawnienia na pytanie „co masz prawo robić?“. Zwykle odbywa się tuż po autoryzacji i sprawdza każdą tożsamość pod kątem reguł dostępu przed zatwierdzeniem lub odrzuceniem żądania. Bez niej każdy zalogowany użytkownik może czytać poufne dane lub uruchamiać uprawnione działania, co właśnie prowadzi do powstawania luk bezpieczeństwa typu BOLA.
Trzy modele pokrywają większość potrzeb:
- Kontrola dostępu oparta na rolach (RBAC) przypisuje uprawnienia do ról, a role do użytkowników. API do tworzenia blogów może mieć role administratora, redaktora i odbiorcy. Nadaje się do stabilnych funkcji i grup roboczych, dlatego jest powszechny w aplikacjach korporacyjnych.
- Kontrola dostępu oparta na atrybutach (ABAC) ocenia atrybuty użytkownika, zasobu oraz środowiska żądania (dział, stopień wrażliwości zasobu, pora dnia, sieć) w odniesieniu do zasad. Pasuje do API, których decyzje są silnie uzależnione od kontekstu lub zmieniają się często.
- Kontrola dostępu oparta na relacjach (ReBAC) umożliwia dostęp na podstawie relacji pomiędzy użytkownikiem a konkretnym zasobem, takiej jak własność lub przynależność do grupy, zwykle sprawdzanej poprzez analizę grafu relacji. Jest doskonałym rozwiązaniem dla produktów współpracy, takich jak udostępnianie dokumentów czy platformy społecznościowe.
Samodzielne tworzenie uprawnień to doskonały sposób na zrozumienie ich niuansów, ale systemy produkcyjne często delegują wydawanie i weryfikację tokenów na dostawcę tożsamości. Gdy API jest zarejestrowane u takiego dostawcy jak Microsoft Entra ID i skonfigurowane do przyjmowania tokenów typu bearer, wrażliwe punkty końcowe weryfikują zakresy i role każdego tokena przed jego wykorzystaniem. Nieprawidłowy token lub brak uprawnień powoduje błąd 401 Unauthorized. W Express ochroniona trasa wygląda w ten sposób:
app.get(
"/api/orders",
passport.authenticate("oauth-bearer", { session: false }),
(req, res) => {
res.json({ message: "Protected resource." });
}
);
Pamiętaj, że zweryfikowany token ustala jedynie ogólne uprawnienia. Sprawdzenia na poziomie obiektu, takie jak „czy ten zamówienie należy do tego użytkownika?”, muszą nadal odbywać się w twoim obsługiwaczu lub warstwie danych, ponieważ żaden dostawca tożsamości nie wie, kto posiada wiersz 4812 w twojej bazie danych.
Jeśli chodzi o same protokoły, należy polegać na standardach branżowych takich jak OAuth 2.0, OpenID Connect i SAML. Można samodzielnie zaimplementować te procesy lub powierzyć je dostawcom usług tożsamości, takim jak Ping Identity, Okta, Microsoft Entra ID, AWS czy IBM Security Verify.
3. Szyfrowanie: ochrona danych w ruchu i w spoczynku
Szyfrowanie przekształca dane czytelne w tekst szyfrowany, który jest bezużyteczny bez odpowiedniego klucza. TLS chroni dane podczas ich przesyłania; szyfrowanie w spoczynku chroni je tam, gdzie są przechowywane, w tym w bazie danych. Systemy wrażliwe zazwyczaj wykorzystują oba podejścia, ponieważ bez szyfrowania dane takie jak informacje finansowe mogą zostać przechwycone lub skradzione z naruszonych źródeł przechowywania.
Główne podejścia polegają na kompromisie pomiędzy szybkością a zarządzaniem kluczami:
- Szyfrowanie symetryczne wykorzystuje jeden wspólny klucz. Jest bardzo szybkie i dobrze radzi sobie z dużymi ilościami danych, co czyni je odpowiednim wyborem do szyfrowania danych w spoczynku oraz treści przesyłanych, ale obie strony muszą bezpiecznie przechowywać ten sam tajny klucz.
- Szyfrowanie asymetryczne wykorzystuje parę kluczy publicznego i prywatnego, więc nie ma potrzeby wymiany żadnego wspólnego sekretu. Jest znacznie wolniejsze i nadaje się tylko do szyfrowania małych fragmentów danych.
- Szyfrowanie hybrydowe łączy te dwa podejścia: kryptografia asymetryczna chroni klucz symetryczny, a ten z kolei chroni większość danych. Dzięki temu uzyskuje się zalety wymiany kluczy z pierwszego podejścia w połączeniu ze szybkością drugiego.
Dla szyfrowania symetrycznego wbudowany w Node moduł crypto obsługuje AES-256-GCM. Poniższy mechanizm serializuje ciało żądania, generuje nowy wektor inicjalizacyjny o długości 12 bajtów, szyfruje dane i zwraca wektor inicjalizacyjny, tag autoryzacji GCM oraz tekst szyfrowany w postaci ciągów hexadecymalnych.
const crypto = require("crypto");
const algorithm = "aes-256-gcm";
const key = Buffer.from(process.env.ENCRYPTION_KEY, "hex");
app.post("/api/orders", (req, res) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(algorithm, key, iv);
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(req.body), "utf8"),
cipher.final(),
]);
const payload = {
iv: iv.toString("hex"),
tag: cipher.getAuthTag().toString("hex"),
data: encrypted.toString("hex"),
};
// Store or transmit the encrypted payload
res.json(payload);
});
Kilka czynników sprawia, że to rozwiązanie jest poprawne. Klucz musi mieć dokładnie 32 bajty (64 znaki hexadecymalne w ENCRYPTION_KEY) i powinien pochodzić z menedżera sekretów, a nie z kodu źródłowego. IV musi być unikalny dla każdego szyfrowania przy użyciu tego samego klucza; ponowne użycie IV w protokole GCM ma katastrofalne skutki, dlatego jest generowane na każde żądanie. Tag autoryzacyjny umożliwia stronie dekodującej wykrycie modyfikacji, więc musi być przechowywany razem z tekstem szyfrowanym i sprawdzany podczas dekodowania. W rzeczywistym serwisie ten ciężar danych byłby przechowywany trwale lub przekazywany dalej, zamiast odsyłany z powrotem do użytkownika, jak to robi demo.
Szyfrowanie asymetryczne jest również dostępne w tym samym module. Dane szyfrowane kluczem publicznym mogą być odszyfrowane tylko za pomocą odpowiadającego im klucza prywatnego:
const crypto = require("crypto");
const encrypted = crypto.publicEncrypt(
publicKey,
Buffer.from("Sensitive API data")
);
Ponieważ RSA może szyfrować tylko dane o rozmiarze mniejszym niż wielkość jego klucza, publicEncrypt nadaje się do szyfrowania krótkich wartości, takich jak pole tajne lub klucz symetryczny, a nie całych dokumentów. To ograniczenie jest rozwiązywane w hybrydowym podejściu: generuje się tymczasowy klucz AES, którym szyfruje się dane, a następnie ten klucz AES szyfruje się za pomocą publicznego klucza RSA odbiorcy, po czym wysyła się oba elementy. Odbiorca używa swojego klucza prywatnego, aby odzyskać klucz AES, a następnie odszyfrować dane. Większość API nie będzie potrzebować tego w kodzie aplikacji, ponieważ TLS już realizuje podobną wymianę, ale warto to zrozumieć w kontekście scenariuszy szyfrowania końcówka-końcówka.
4. Walidacja i oczyszczanie danych wejściowych
Gdy twoja API przyjmie dane od klienta, nie możesz przewidzieć, co dotrze dalej. Błędnie sformatowane treści, fragmenty SQL oraz ładunki skryptów wyglądają jak zwykłe ciągi znaków, dopóki coś ich nie zinterpretuje. Dwie komplementarne techniki rozwiązują ten problem. Walidacja odrzuca dane, które naruszają twoje zasady strukturalne i semantyczne. Sanitizacja przekształca przyjęte dane w bezpieczną, znormalizowaną formę przed tym, jak trafią do obsługujących je funkcji.
Najpierw wymuś zgodność typu treści
Najtańszą weryfikacją jest format samej żądania. Ten prosty mechanizm pośredniczący wykorzystuje req.is(), aby sprawdzić wartość Content-Type; w przeciwnym razie zwraca błąd 415 Unsupported Media Type. Może być zainstalowany globalnie, dla konkretnego routera lub dla poszczególnych punktów końcowych.
const requireContentType = (type) => (req, res, next) => {
if (!req.is(type)) {
return res.status(415).json({ error: "Unsupported Media Type", });
}
next();
};
app.post("/api/users", requireContentType("application/json"),
(req, res) => {
res.json({ message: "User created." });
}
);
Waliduj strukturę i znaczenie treści
Gdy format został już narzucony, kolejny warstwa sprawdza, czy żądanie jest poprawnie sformatowane. Korzystając z express-validator, należy przechowywać reguły w dedykowanym module walidatora. Ten wymaga składniowo poprawnego adresu e-mail, wykonuje asynchroniczną, dostosowaną weryfikację, która odrzuca adresy już istniejące w bazie danych, oraz narzuca minimalną długość hasła wynoszącą osiem znaków.
const { body } = require("express-validator");
const { getUserEmail } = require("../db/queries");
const validateRegistration = [
body("email")
.isEmail()
.withMessage("Invalid email format")
.custom(async (value) => {
if (await getUserEmail(value)) {
throw new Error("Email is already in use");
}
return true;
}),
body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long"),
];
module.exports = { validateRegistration }
Następnie tablica walidatorów jest umieszczana w łańcuchu middleware routy. Wewnątrz obsługiwanego żądania validationResult(req) zbiera wszystkie błędy, a ruta zwraca odpowiedź 400 z pełną listą błędów zamiast kontynuować obsługę żądania.
const { validationResult } = require("express-validator");
const { validateRegistration } = require("../validators/userValidator");
app.post("/api/register", validateRegistration, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
});
Oczyszczanie po walidacji
Ponieważ Express przetwarza middleware w określonej kolejności, łańcuch operacji oczyszczania może znajdować się bezpośrednio po walidacji. W tym przypadku imię jest przycięte i zabezpieczone przed kodem HTML, a adres e-mail normalizowany.
const sanitizeRegistration = [
body("firstName").trim().escape(),
body("email").normalizeEmail(),
];
app.post(
"/api/register",
validateRegistration,
sanitizeRegistration,
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
}
);
Tutaj kolejność ma istotne znaczenie. Sprawdzenie unikalności w walidatorze odbywa się przed wywołaniem normalizeEmail(), więc dwie różne pisownie tego samego adresu mogą ominąć tę kontrolę i doprowadzić do utworzenia podwójnych kont. Normalizacja przed wyszukiwaniem lub narzucenie wymogu unikalności na znormalizowanej wartości na poziomie bazy danych eliminuje ten problem. Należy również uważnie podchodzić do funkcji escape(): kodowanie HTML wejścia chroni szablony, które wyświetlają tę wartość, ale zmienia przechowywane dane; wiele zespołów woli przechowywać surowe wartości i kodować je dopiero przy wyświetlaniu. Jeśli rozważasz biblioteki do walidacji, nasze porównanie Zod i express-validator omawia zalety i wady obu rozwiązań.
Używaj zapytań parametryzowanych do pracy z bazą danych
Nigdy nie twórz zapytań SQL poprzez łączenie danych wprowadzonych przez użytkownika. Klienci bazy danych takie jak pg oraz narzędzia ORM takie jak Prisma obsługują zapytania parametryzowane, które wysyłają tekst zapytania oraz wartości oddzielnie, dzięki czemu baza danych zawsze traktuje wprowadzone dane jako zwykłe dane, a nie jako wykonywalny SQL.
W przypadku pg utwórz raz zbiór połączeń i wyeksportuj go do swoich modułów danych:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;
Zapytania używają wtedy oznaczeń numerowanych ($1, $2) z wartościami dostarczanymi jako oddzielny tabliczka. Nawet jeśli email zawiera cudzysłów, a po nim instrukcję DROP TABLE, jest on przechowywany jako zwykła ciąg znaków.
app.post("/api/users", async (req, res) => {
const { email, name } = req.body;
await pool.query(
"INSERT INTO users (email, name) VALUES ($1, $2)",
[email, name]
);
res.status(201).json({ message: "User created." });
});
Wspólnie te elementy tworzą złożoną strukturę przetwarzania danych. Express-validator umożliwia izolację reguł jako jednostek wielokrotnie używalnych, które działają jako middleware i zgłaszają każdą niepowodzenie, natomiast parametryzacja zapewnia, że nawet dane, które przekroczyły walidację, nie mogą zmodyfikować zapytań.
5. Ograniczanie szybkości i throttling
Ograniczanie szybkości określa, ile zapytań klient może wysłać w danym oknie czasowym. Zapobiega to atakom typu brute-force i denial-of-service, a także uniemożliwia jednemu intensywnemu użytkownikowi wyczerpanie zasobów pozostałych.
Ograniczenia mogą być stosowane w różnych wymiarach:
- Dla każdego klienta: zapytania są liczone według klucza API lub adresu IP. Gdy klient osiągnie ustalony limit, musi czekać, aż okno czasowe się zresetuje, lub uzyska wyższy limit, zwykle w pakiecie płatnym.
- Ze względu na lokalizację lub czas: limity różnią się w zależności od regionu lub okna czasowego – na przykład pozwala się na większy ruch z regionów, w których działają klienci, a ogranicza go tam, skąd pochodzi podejrzany ruch.
Istnieje wiele algorytmów (okno stałe, okno przesuwane, bufor tokenów), a aby rozpocząć pracę, nie musisz ich samodzielnie implementować. Middleware express-rate-limit domyślnie liczy żądania według adresu IP. Poniższy przykład ustawia ogólny limit 100 żądań na 15 minut dla wszystkiego pod adresem /api oraz znacznie bardziej restrykcyjny limit pięciu prób na pięć minut dla logowania, z własną wiadomością dla odrzuconych żądań.
const rateLimit = require("express-rate-limit");
// Apply to all API routes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
});
// Apply stricter limits to authentication endpoints
const loginLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 5,
message: "Too many login attempts. Please try again later.",
});
app.use("/api", apiLimiter);
app.post("/api/login", loginLimiter, (req, res) => {
res.json({ message: "Login successful." });
});
Publiczne API zazwyczaj przydzielają każdemu użytkownikowi klucz, a ograniczanie dostępu na podstawie tego klucza jest bardziej sprawiedliwe niż ograniczanie go na podstawie adresu IP, ponieważ wielu użytkowników może dzielić się jednym adresem za pośrednictwem proxy firmy. Specjalny keyGenerator odczytuje nagłówek X-API-Key i używa go jako identyfikatora licznika.
const rateLimit = require("express-rate-limit");
const apiKeyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
keyGenerator: (req) => req.get("X-API-Key"),
});
app.use("/api", apiKeyLimiter);
W obecnym ujęciu każda prośba, która pomija ten nagłówek, generuje taki sam klucz undefined i korzysta z tego samego bufora. W praktyce należy odrzucać prośby bez klucza wcześniej w łańcuchu przetwarzania lub używać adresu IP jako alternatywy.
Ograniczanie liczby żądań do kosztownych punktów końcowych
Regulacja prędkości decyduje o tym, jak szybko przyjmowane są żądania, aby nagłe wzrosty nie przełamały możliwości serwisu. W następnej konfiguracji używany jest ten sam middleware z bardzo krótkimi oknami czasowymi: maksymalnie dziesięć żądań na sekundę w ramach API oraz tylko jedno żądanie wyszukiwania co dwie sekundy na klienta, ponieważ wyszukiwanie jest najbardziej wymagającym pod względem zasobów punktem końcowym.
const rateLimit = require("express-rate-limit");
// Throttle all API requests
const apiThrottle = rateLimit({
windowMs: 1000, // 1 second
max: 10, // Allow up to 10 requests per second
});
// Apply a stricter throttle to resource-intensive endpoints
const searchThrottle = rateLimit({
windowMs: 2000, // 2 seconds
max: 1, // Allow 1 request every 2 seconds
message: "Please wait before sending another search request.",
});
app.use("/api", apiThrottle);
app.get("/api/search", searchThrottle, (req, res) => {
res.json({ results: [] });
});
Ściśle mówiąc, to nadal jest ograniczanie szybkości z małymi oknami czasowymi: nadmiarowe żądania są odrzucane z kodem 429, a nie opóźniane. Jeśli chcesz prawdziwego throttlingu, który spowalnia klientów przed ich odrzuceniem, pakiet pomocniczy taki jak express-slow-down wprowadza stopniowe opóźnienia. Należy również zauważyć, że domyślna pamięć operacyjna liczy żądania na poziomie procesu, więc za load balancerem z kilkoma instancjami potrzebna jest wspólna baza danych, tak jak Redis, aby ograniczenia były skuteczne. W najnowszych wersjach express-rate-limit opcja max nazywana jest również limit; sprawdź dokumentację do wersji, którą instalujesz. Jeśli szukasz lekkiej alternatywy w TypeScript, przeczytaj nasz artykuł o minimalnym ograniczaczu szybkości dla Express.
6. Logowanie, monitorowanie i wykrywanie incydentów
Nie możesz odpowiedzieć na atak, którego nigdy nie widzisz. Rejestracja działań zapisuje żądania i odpowiedzi wraz z ich metadanymi, kontekstem, czasem realizacji oraz kodami błędów, dzięki czemu możesz rozwiązywać problemy, przeprowadzać audyty i zrozumieć rzeczywiste użycie. Monitorowanie obserwuje aktywność w czasie rzeczywistym, śledzi takie wskaźniki jak opóźnienia, wskaźniki błędów i przepustowość oraz ujawnia anomalie, które mogą wskazywać na nadużycia, niespełnienie celów poziomu usług lub wykorzystywanie wrażliwości.
Główne podejścia wraz z ich zaletami i wadami:
- Rejestracja żądań za pomocą narzędzi typu middleware, takich jak Morgan, tanio przechwytuje każde przychodzące żądanie HTTP, ale nie dostarcza informacji o stanie systemu.
Każdy z tych elementów łączy się z Express. Poniżej przedstawiono cztery powszechne elementy budulcowe.
Logowanie żądań za pomocą Morgan
Rегистrując Morgan w formacie combined, dla każdego żądania zapisywana jest linia w stylu Apache, zawierająca metodę, ścieżkę, status, rozmiar odpowiedzi oraz user agenta.
const express = require("express");
const morgan = require("morgan");
const app = express();
// Log every incoming request
app.use(morgan("combined"));
app.get("/api/users", (req, res) => {
res.json({ message: "Users retrieved successfully." });
});
Strukturalne logowanie aplikacji za pomocą Winston
Winston rejestruje zdarzenia jako strukturalne obiekty. Logowanie identyfikatorów użytkownika i zamówienia podczas ich tworzenia umożliwia utworzenie śladu audytowego, który można później przeszukać.
const winston = require("winston");
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
],
});
app.post("/api/orders", (req, res) => {
logger.info("Order created", {
userId: req.user.id,
orderId: req.body.id,
});
res.status(201).json({ message: "Order created." });
});
Należy uważać na to, co trafia do logów. Identyfikatory użytkowników są w porządku; hasła, tokeny, pełne numery kart oraz całe treści żądań nie nadają się do logowania, a logi są częstym źródłem wycieku danych poufnych.
Centralizowane logowanie błędów
Middleware do obsługi błędów typu Express, rozpoznawalny po czterech argumentach, łapie błędy z dowolnej trasy. Ten konkretny zapisuje komunikat wraz z ścieżką i metodą oraz zwraca ogólny kod 500, dzięki czemu ślady wywołań i wewnętrzne szczegóły nigdy nie trafiają do klienta.
app.use((err, req, res, next) => {
/* Logger is built as an independent module or class */
logger.error(err.message, {
path: req.originalUrl,
method: req.method,
});
res.status(500).json({
error: "Internal Server Error",
});
});
Ujawnianie metryk dla Prometheus
Biblioteka prom-client zbiera standardowe metryki procesu Node.js i udostępnia je na endpointzie /metrics, aby Prometheus mógł je pobrać.
const client = require("prom-client");
client.collectDefaultMetrics();
app.get("/metrics", async (req, res) => {
res.set("Content-Type", client.register.contentType);
res.end(await client.register.metrics());
});
Ten endpoint ujawnia wewnętrzne szczegóły dotyczące twojej usługi, dlatego ogranicz dostęp do niego do sieci monitoringu lub umieść go za autoryzacją, zamiast pozostawiać go publicznego.
Tutaj przydają się frameworki o określonej architekturze. NestJS zawiera wbudowany logger oraz strukturę, która ułatwia włączenie funkcji logowania i pomiarów dla każdego endpointu, podczas gdy w frameworku bez takiej specyfikiki, jak Express, trzeba celowo dodać funkcje logowania, zazwyczaj jako middleware działający przed wysłaniem odpowiedzi. Wykrywanie incydentów opiera się następnie na tych logach: powiadamiania o podejrzanych wzorcach, takich jak gwałtowny wzrost liczby odpowiedzi 401 lub odrzucenia z powodu ograniczeń szybkości, są kierowane do kanału komunikacji, który faktycznie monitoruje zespół.
Bезpieczeństwo jako proces ciągły
Żaden pojedynczy artykuł nie obejmuje wszystkiego, ale przed każdym wydaniem można sprawdzić, czy API spełnia następujące podstawowe wymagania:
- Każdy endpoint jest dostępny wyłącznie przez HTTPS.
- Zastosowano OAuth lub równoważny mechanizm tokenów.
- Wydawane JWT mają określony termin ważności.
- Ograniczenia chronią wszystkie trasy, przy czym te dotyczące logowania są bardziej restrykcyjne.
W przykładach użyto Express, ale te same zasady odnoszą się również do innych frameworków backendowych, z których większość albo integruje te narzędzia bezpośrednio, albo oferuje ich lokalne odpowiedniki. NestJS na przykład obsługuje weryfikację żądań za pomocą obiektów przenoszenia danych. Dokumentacja Twojego frameworka pokaże idiomatyczną wersję każdego warstwy.
To same zasady stanowią również podstawę dla implementacji typu cloud-native na platformach takich jak Azure, Google Cloud i AWS, które dodają własne bramy wejściowe, usługi tożsamości oraz zarządzane mechanizmy ograniczania przepustowości. Praktyki specyficzne dla chmury wymagają osobnego potraktowania, ale powyższe warstwy już w dużej mierze przygotowują API do przejścia audytu bezpieczeństwa.
Główne wnioski
- Rozdzielaj weryfikację uprawnień od autoryzacji na poziomie każdej prośby: sprawdź hasło raz, a następnie polegaj na krótkotrwałych, podpisanych tokenach.
- Autoryzacja to nie upoważnienie. Waliduj zakresy i role, a także sprawdzaj właścicielstwo każdego obiektu, który jest dotykany przez prośbę.
- Dla danych w stanie spoczynku używaj AES-GCM z unikalnym IV przy każdej operacji; asymetryczną kryptografię zachowaj do małych wartości i wymiany kluczy.
Literatura pokrewna
- Gdy autoryzacja JWT staje się zależna od stanu: argument za sesjami po stronie serwera w Node — Dowiedz się, jak listy blokujące i magazyny odnowień sprawiają, że autoryzacja JWT jest krucha, jak sesje Express z bazą Postgres je upraszczają oraz w jakich przypadkach JWT nadal są przydatne.
- Budowanie autoryzacji JWT przygotowanej do użycia w produkcji w API Node.js — Jak haszować hasła, wydawać krótkotrwałe tokeny JWT, dodawać tokeny odnowienia i mechanizmy anulowania, oddzielać uprawnienia, chronić się przed atakami siłowymi oraz sprawdzać, czy autoryzacja działa poprawnie w przypadku błędów.
- Biometryczna autoryzacja poziomowa na iOS i Android z kluczami przytwierdzonymi do hardwaru — Dowiedz się, dlaczego sam Face ID nie dostarcza żadnych informacji twojemu serwerowi, oraz jak wykorzystać klucze Secure Enclave i Android Keystore do podpisywania jednorazowych wyzwań od serwera.
- Od zapisu do URL: Bezpieczne przechowywanie i serwowanie plików użytkowników w Express — Dowiedz się, gdzie aplikacje Express powinny przechowywać załadowane pliki, jak express.static mapuje folder na URL-y oraz jakie środki ochrony zapobiegają temu, by pliki przesłane przez użytkowników stały się luką bezpieczeństwa.
- Kontrola dostępu oparta na rolach w Express z JWT i dwoma middlewareami — Przeczytaj, jak wdrożyć kontrolę dostępu opartą na rolach w API Express poprzez połączenie middleware’a autoryzacji JWT z funkcją authorize() nadającą się do ponownego użycia, oraz kiedy należy zwrócić błąd 401, a kiedy 403.