Strona główna / Artykuły

Bezpieczeństwo API: JWT i OAuth, weryfikacja danych, ograniczanie szybkości żądań, monitorowanie

Zastosowanie zasad bezpieczeństwa API w praktyce: autoryzacja i uwierzytelnianie za pomocą JWT i OAuth, szyfrowanie, weryfikacja danych wejściowych, ograniczanie szybkości żądań oraz rejestracja działań w celu wykrywania ataków.

Wydawca: REACTAPP.TOP22 min czytania5067 słów

Źródła: What Are the Best Practices for API Security? A Complete Guide to Securing Modern APIs

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 utworzone na podstawie surowych danych wejściowych, punkt logowania, który chętnie przyjmuje 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, po wdrożeniu poprawki oraz za każdym razem, gdy zmienia się zależność lub trasa. Regularne przeprowadzanie tych sprawdzeń pozwala wykrywać słabości, gdy są jeszcze niewielkie.

Dlaczego bezpieczeństwo API wymaga specjalnej uwagi

Współczesne produkty są coraz częściej tworzone przy użyciu 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, łatwy do wykorzystania punkt wejścia. Badania branżowe regularnie łączą dużą liczbę incydentów bezpieczeństwa z API; raport Traceable z 2023 roku przypisuje im 74% wszystkich naruszeń danych.

API często znajdują się bezpośrednio przy najbardziej wrażliwych danych, które posiada firma: platformach identyfikacyjnych z danymi osobowymi, dokumentach finansowych, 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. Ponieważ stawkę stanowi tak wysoki poziom ryzyka, bezpieczeństwo powinno być uwzględniane w umowach na poziomie usług obok wskaźników 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, które warto znać, od OWASP po OAuth i JWT

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 tożsamości oraz sposób, w jaki klienci go weryfikują, co umożliwia współpracę między różnymi dostawcami usług tożsamości przy logowaniu jednorazowym i pobieraniu profilu użytkownika.
  • JSON Web Tokens (JWTs): kompaktowy format tokena podpisanego. W odróżnieniu od nieprzejrzystego ciasteczka sesji, JWT zawiera swoje dane (ID użytkownika, rolę, datę wygaśnięcia) bezpośrednio w samym tokenie, a serwer weryfikuje podpis zamiast wyszukiwać informacje o sesji. Należy pamiętać, że standardowy podpisany JWT jest kodowany, a nie szyfrowany, więc każdy, kto go posiada, może odczytać jego zawartość.
  • Transport Layer Security (TLS): protokół kryptograficzny stanowiący podstawę HTTPS. Zapewnia szyfrowanie danych w trakcie przesyłania, autoryzację stron komunikujących się oraz sprawdzanie integralności, które wykrywają modyfikacje.
  • Zero Trust: podejście architektoniczne, które traktuje każdego użytkownika i usługę jako niewiarygodnego, dopóki nie zostanie to zweryfikowane. Opiera się na wyraźnej weryfikacji, zasadzie najmniejszych uprawnień oraz założeniu, że naruszenie bezpieczeństwa już gdzieś miało miejsce.
  • NIST Cybersecurity Framework: wytyczne dobrze pasujące do architektur chmurowych i mikrosług, z naciskiem na autoryzację, uprawnienia i ochronę danych.
  • Profilowie API klasy finansowej (FAPI): wzmocnione profile OAuth i OIDC przeznaczone do zastosowań o wysokim stopniu ryzyka, takich jak bankowość, fintech i dane regulowane. Wymagają silnej autoryzacji klienta, ścisłego obsługi żądań i odpowiedzi oraz tokenów ograniczonych pod względem nadawcy, które stają się bezużyteczne w przypadku kradzieży, co zapobiega oszustwom i poprawia interoperacyjność.
  • Stanowią one solidne podstawy, choć nie są wykazem wyczerpującym.

    Zagrożenia, przed którymi się bronicie

    Najszybciej występujące w rzeczywistych API słabości to:

    1. 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.
    2. 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.
    3. 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.
    4. Injekcja poleceń: niezaufane dane z żądania trafiają do wiersza poleceń systemu lub procesora poleceń, co pozwala atakownikowi uruchamiać dowolne polecenia z uprawnieniami procesu serwera.
  • Skryptowanie międzystronnicze (XSS): wstrzyknięty skrypt uruchamia się w przeglądarce ofiary w kontekście Twojej aplikacji, dzięki czemu atakujący może działać jako ten użytkownik i odczytywać dane, które normalnie byłyby chronione przez zasadę tego samego źródła.
  • Niewłaściwa konfiguracja bezpieczeństwa: wyciek kluczy API, ujawnione zmienne środowiskowe, zbyt luźne ustawienia domyślne. Skompromitowane dane dostępowe mogą być wykorzystane do wywoływania Twojego API lub do generowania rachunków za usługi płatne od stron trzecich.
  • Ujawnianie nadmiernej ilości lub wrażliwych danych: mechanizm obsługi zwraca cały obiekt bazy danych zamiast tylko pól potrzebnych klientowi. Nawet jeśli interfejs użytkownika ich nie wyświetla, dane te pozostają w pamięci cach, lokalnej pamięci przeglądarki oraz w zakładce sieciowej.
  • Odrzucenie usługi (włączając ataki rozproszone): fala żądań wyczerpuje zasoby serwera lub całkowicie wyłącza API.
  • Zależności od stron trzecich: wasza API jest również klientem innych API i pakietów. Każda z nich powiększa waszą powierzchnię ataku, a naruszenie bezpieczeństwa lub przerwa w ich działaniu staje się waszym problemem.
  • 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 za pomocą haseł i JWT: potwierdzenie tożsamości żądającego

    Każda chroniona API powinna wymagać od klienta potwierdzenia 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 hasle, 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ę hybrydowy model. 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ć lokalną strategię oraz strategię JWT

    Strategia lokalna jest wykonywana raz, podczas logowania, i jest odpowiedzialna za wyszukiwanie użytkownika pod adresem e-mail oraz porównywanie podanego hasła z zapisanym hashem. Strategia JWT jest uruchamiana przy każdym zabezpieczonym żądaniu: wyodrębnia token z nagłówka Authorization: Bearer, weryfikuje podpis w oparciu o JWT_SECRET oraz identyfikuje użytkownika wspomnianego w treści żądania. Eksportowanie z góry skonfigurowanego middleware’u authenticateJWT z ustawieniem session: false wyraźnie wskazuje na brak stanu sesji.

    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ę prawdziwa praca związana z bezpieczeństwem. Do porównywania haseł należy używać wolnego algorytmu haszowania z solą, takiego jak bcrypt lub Argon2, oraz zwracać ten sam ogólny komunikat o błędzie niezależnie od tego, czy adres e-mail jest nieznany, czy hasło błędne – w ten sposób punkt końcowy nie może być wykorzystany do odkrycia, jakie konta istnieją.

    Krok 2: uwierzytelnianie podczas logowania i podpisywanie tokena

    Obsługa logowania wywołuje lokalną strategię za pośrednictwem specjalnej wtyczki. 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ą wygaśnięcia określoną w 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 ogranicza czas, przez jaki 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 naszej strategii tokenów odświeżania dla systemów autoryzacji Node.js. Ponadto odpowiedź zwraca user w niezmienionej formie. Jeśli ten obiekt to surowy rekord z bazy danych, może zawierać hasło w formie hashu oraz pola wewnętrzne, co stanowi dokładnie to nadmierne ujawnienie danych, o którym mówiono 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

    Przy wyeksportowanym middleware 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 i zakresy OAuth: 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 zasad 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 powszechnie używany 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 przeszukiwanie 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 dostawcy takiego jak Microsoft Entra ID i skonfigurowane tak, aby przyjmować tokeny nośnikowe, wrażliwe punkty końcowe weryfikują zakresy i role każdego tokena przed jego wykorzystaniem. Nieprawidłowy token lub brak uprawnień powodują 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." });
      }
    );
    

    Należy pamiętać, że zweryfikowany token ustala jedynie ogólne uprawnienia. Sprawdzenia na poziomie obiektu, takie jak „czy ta zamówienie należy do tego użytkownika?”, muszą nadal odbywać się w twoim mechanizmie obsługi 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żesz sam zaimplementować te procesy lub powierzyć je dostawcom 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 w trakcie ich przesyłania, natomiast szyfrowanie w stanie spoczynku chroni je w miejscu przechowywania, 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 naruszonego magazynu.

    Główne metody 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 stanie spoczynku oraz treści przesyłanych, ale obie strony muszą bezpiecznie przechowywać ten sam tajny klucz.
    • Szyfrowanie asymetryczne wykorzystuje parę kluczy publicznego i prywatnego, dzięki czemu nie ma potrzeby wymiany 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 klucz symetryczny z kolei chroni samą zawartość danych. Dzięki temu uzyskujemy zalety wymiany kluczy z pierwszego podejścia oraz szybkość drugiego.
  • W przypadku szyfrowania symetrycznego wbudowany w Node moduł crypto obsługuje standard AES-256-GCM. Poniższy mechanizm serializuje treść żądania, generuje nowy wektor inicjalizacyjny o długości 12 bajtów, szyfruje dane, a następnie zwraca wektor inicjalizacyjny, tag autoryzacyjny 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ć unikalne dla każdego szyfrowania przy użyciu tego samego klucza; ponowne użycie IV w protokole GCM ma katastrofalne konsekwencje, dlatego jest generowane na bieżąco przy każdej prośbie. Tag autoryzacyjny umożliwia stronie dekodującej wykrycie modyfikacji danych, 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 wyłącznie 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ż rozmiar jego klucza, publicEncrypt nadaje się do krótkich wartości, takich jak pole tajne lub klucz symetryczny, a nie do całych dokumentów. To ograniczenie jest rozwiązywane w hybrydowym procesie: generuje się tymczasowy klucz AES, szyfruje się nim dane, a następnie szyfruje się klucz AES kluczem publicznym 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 nigdy nie będzie potrzebować tego w kodzie aplikacji, ponieważ TLS już przeprowadza podobną wymianę, ale warto to zrozumieć w kontekście scenariuszy szyfrowania końcówka-do-końcówki.

    4. Walidacja i oczyszczanie danych wejściowych

    Gdy twoja API przyjmie dane od klienta, nie możesz przewidzieć, co przyjdzie dalej. Błędnie sformatowane dane, fragmenty SQL oraz treści skryptów wyglądają jak zwykłe łańcuchy 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ę, zanim dotrą do obsługujących je funkcji.

    Najpierw wdrożenie typu treści

    Najtańszą metodą sprawdzenia jest format samej żądania. Ten prosty mechanizm pośredniczący wykorzystuje req.is(), aby potwierdzić wartość Content-Type, a 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." });
      }
    );
    

    Walidacja kształtu i znaczenia ciała żądania

    Gdy format jest już narzucony, kolejna warstwa sprawdza, czy żądanie jest prawidłowo sformatowane. Korzystając z express-validator, przechowuj zasady w dedykowanym module walidatora. Ten wymaga syntaktycznie poprawnego adresu e-mail, wykonuje asynchroniczną, dostosowaną kontrolę, 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 }
    

    Tablica walidatorów jest następnie umieszczana w łańcuchu middleware trasy. Wewnątrz obsługiwanego elementu validationResult(req) zbiera wszystkie błędy, a trasa zwraca kod 400 wraz z pełną listą błędów, zamiast kontynuować działanie.

    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." });
    });
    

    Sanitizacja po walidacji

    Ponieważ Express przetwarza middleware w określonej kolejności, łańcuch sanitizacji może znajdować się bezpośrednio po walidacji. W tym przypadku imię jest skracane i zabezpieczane 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 wymuszanie unikalności wartości po normalizacji 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, dlatego 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 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 dla swoich modułów danych:

    const { Pool } = require("pg");
    
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
    
    module.exports = pool;
    

    Zapytania wykorzystują wtedy numerowane miejsca zastępcze ($1, $2) z wartościami podanymi jako oddzielny tabliczka. Nawet jeśli email zawiera cudzysłów, a po nim DROP TABLE, jest przechowywany jako literalna 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." });
    });
    

    W połączeniu te elementy tworzą wielowarstwową architekturę. 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 oznacza, że nawet dane, które przekroczą walidację, nie mogą zmodyfikować zapytań.

    5. Ograniczanie częstotliwości i throttling

    Ograniczanie częstotliwości określa, ile zapytań klient może wysłać w określonym oknie czasowym. Zapobiega to atakom typu brute-force i denial-of-service, a także chroni przed sytuacją, w której jeden intensywny użytkownik wykorzystuje zasoby na szkodę 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, zazwyczaj w wersji płatnej.
    • Ze względu na geografię lub czas: ograniczenia różnią się w zależności od regionu lub okna czasowego – na przykład dopuszcza się większą ilość ruchu z regionów, w których działają klienci, a ograniczenia są surowsze tam, skąd pochodzi podejrzany ruch.
    • Ze względu na pojemność serwera: niektóre części API są kierowane do dedykowanej infrastruktury z własnymi ograniczeniami, takimi jak mała grupa procesów w tle przeznaczonych do wykonywania kosztownych zadań.

    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 surowszy 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);
    

    Zgodnie z opisem, każda prośba, która pomija nagłówek, generuje tę samą wartość klucza 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 kosztownych zapytań

    Ograniczanie liczby zapytań wpływa na szybkość ich przyjmowania, aby nagłe wzrosty nie przełamały zdolności obsługi usługi. W następnej konfiguracji używany jest ten sam middleware z bardzo krótkimi oknami czasowymi: maksymalnie dziesięć zapytań na sekundę w całym API oraz tylko jedno zapytanie wyszukiwawcze 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, jest to nadal ograniczanie szybkości z małymi oknami czasowymi: nadmiarowe żądania są odrzucane z kodem 429, a nie opóźniane. Jeśli chcesz prawdziwego ograniczania przepustowości, które 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ślny magazyn w pamięci liczy żądania na proces, więc za load balancerem z kilkoma instancjami potrzebny jest wspólny magazyn, takie 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 i 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ą middleware takiego jak Morgan tanio przechwytuje każde przychodzące żądanie HTTP, ale nie dostarcza informacji o stanie systemu.
    • Logowanie aplikacji za pomocą Winston, Pino lub Bunyan rejestruje wydarzenia biznesowe i związane z bezpieczeństwem, takie jak logowania, operacje na bazie danych oraz błędy. Większe systemy wymagają ustrukturyzowanego wyjścia danych oraz centralnego przechowywania, aby były one przydatne.
    • Metryki z Prometheus i Grafana śledzą szybkość przetwarzania żądań, opóźnienia, wykorzystanie CPU, pamięci oraz częstotliwość występowania błędów. Pokazują one ogólne trendy, ale nie to, co wydarzyło się w ramach pojedynczego żądania.
    • Centralne zarządzanie logami za pomocą ELK Stack, OpenSearch, Splunk lub CloudWatch agreguje logi z różnych usług, jednak przy tym powstają dodatkowe wymagania infrastrukturalne oraz konieczność wykonywania dodatkowej pracy operacyjnej.
    • Monitorowanie wydajności aplikacji z Datadog, New Relic lub Dynatrace łączy logi, metryki, ślady działania aplikacji oraz funkcje alarmowe w jednej platformie, co wiąże się z wyższymi kosztami licencji oraz większą złożonością platformy.

    Każdy z nich łączy się z Express. Poniżej przedstawiono cztery powszechne elementy budulcowe.

    Rejestracja żądań za pomocą Morgan

    Rejestracja Morgan w formacie combined zapisuje linijkę w stylu Apache dla każdego żądania, zawierającą 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 rejestrowanie działań aplikacji za pomocą Winston

    Winston zapisuje zdarzenia jako strukturalne obiekty. Rejestrowanie identyfikatorów użytkownika i zamówienia podczas jego tworzenia tworzy ślad audytowy, który można później przeszukiwać.

    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." });
    });
    

    Bądź ostrożny z tym, co trafia do logów. ID użytkowników są w porządku; hasła, tokeny, pełne numery kart oraz całe treści zapytań nie nadają się do tego, a logi są częstym miejscem wycieku danych wrażliwych.

    Centralizowane logowanie błędów

    Middleware do obsługi błędów w 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 punkt końcowy ujawnia wewnętrzne szczegóły dotyczące twojej usługi, dlatego ogranicz dostęp do niego do sieci monitoringu lub umieść go za mechanizmem autoryzacji, zamiast pozostawiać go publicznym.

    Tutaj przydają się frameworki z z góry określonymi rozwiązaniami. NestJS zawiera wbudowany logger oraz strukturę, która ułatwia dodawanie funkcji logowania i pomiarów do każdego punktu końcowego, podczas gdy w frameworku bez takich ustaleń, jak Express, musisz celowo dodać funkcje logowania, zazwyczaj jako middleware działający przed wysłaniem odpowiedzi. Wykrywanie incydentów opiera się następnie na tych logach: alerty dotyczące podejrzanych wzorców, takich jak gwałtowny wzrost liczby odpowiedzi 401 lub odrzucenia z powodu ograniczeń szybkości, są kierowane do kanału powiadamiania, który faktycznie używa twoz zespół.

    Bezpieczeństwo jako proces ciągły

    Żaden pojedynczy artykuł nie obejmuje wszystkiego, ale przed każdym wydaniem możesz sprawdzić, czy twoja API spełnia te podstawowe wymagania:

    • Każdy punkt końcowy 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.
    • Limity chronią wszystkie ścieżki, przy czym te dotyczące logowania są bardziej restrykcyjne.
    • Każdy wprowadzany danych jest weryfikowany przed użyciem.
    • Zapytania zostały przetestowane pod kątem prób iniekcji.
    • Zasady dostępu zostały sprawdzone, w tym kontrole na poziomie obiektów.
    • Klucze i sekrety przechowywane są poza bazą kodu.
    • Zdarzenia istotne z punktu widzenia bezpieczeństwa są rejestrowane.
    • Konfigurowane są panele kontrolne i alerty.
    • Odpowiedzi zawierają nagłówki bezpieczeństwa.
    • Klienci nigdy nie widzą śladów wywołań ani szczegółów błędów wewnętrznych.
    • Zależności są aktualne i przeszły audyt.

    W przykładach użyto tu 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 walidację żądań za pomocą obiektów do przenoszenia danych. Dokumentacja danego frameworka pokaże idiomatyczną wersję każdej warstwy.

    Te 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, usługi tożsamości oraz zarządzane ograniczenia szybkości. Praktyki specyficzne dla chmury wymagają osobnego omówienia, ale powyższe warstwy już w dużej mierze przygotowują API do przejścia audytu bezpieczeństwa.

    Krótki audyt bezpieczeństwa API Express

    • Rozdzielanie weryfikacji uprawnień od autoryzacji na poziomie każdego żądania: sprawdź hasło raz, a następnie polegaj na krótkotrwałych, podpisanych tokenach.
    • Autoryzacja to nie upoważnienie. Sprawdzaj zakresy i role, a także sprawdzaj właścicielstwo każdego obiektu, którego dotyka żądanie.
    • Dla danych w stanie spoczynku używaj AES-GCM z unikalnym IV przy każdej operacji; zachowaj szyfrowanie asymetryczne na małe wartości i wymianę kluczy.
    • Wdroż stratyfikację w postaci sprawdzania typu treści, walidacji, oczyszczania danych oraz zapytań parametryzowanych, biorąc pod uwagę kolejność ich wykonywania.
    • Wprowadź ograniczenia szybkości dla wszystkiego, stosuj surowsze limity dla logowania i kosztownych punktów końcowych, a gdy uruchamiasz więcej niż jedną instancję, użyj wspólnego magazynu.
    • Zapisuj wydarzenia istotne z punktu widzenia bezpieczeństwa, unikając zapisywania tajemnic, i przekształcaj te logi w alerty, na które ktoś będzie mógł zareagować.

    Literatura pokrewna

  • Biometryczna autentyfikacja poziomowa na iOS i Android z kluczami przytwierdzonymi do hardwaru: Dowiedz się, dlaczego sam Face ID nie dostarcza żadnych dowodów dla twojego serwera, oraz jak wykorzystać Secure Enclave i klucze Android Keystore do podpisywania jednorazowych wyzwań serwerowych.
  • 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 ochronne zapobiegają temu, by załadowane pliki stały się luką bezpieczeństwa.
  • Kontrola dostępu oparta na rolach w Express z JWT i dwoma middlewareami: Dowiedz się, jak wdrożyć kontrolę dostępu opartą na rolach w API Express, łącząc middleware autoryzacji JWT z wielokrotnie używalną funkcją authorize(), oraz kiedy należy zwrócić błąd 401, a kiedy 403.
  • Przenoszenie JWT z aplikacji React z localStorage do HttpOnly Cookies: Dowiedz się, dlaczego JWT przechowywane w localStorage są podatne na ataki XSS, jak HttpOnly cookies z opcją SameSite, rotacją i mechanizmem BFF zmniejszają ryzyko, oraz jakie pułapki nadal istnieją w aplikacjach React.