CSRF to atak, w którym obca strona wysyła w imieniu zalogowanego użytkownika żądanie do atakowanej aplikacji, np. zmianę adresu e-mail czy hasła. Przeglądarka dołącza do niego ciasteczko sesji, więc serwer nie odróżnia go od prawdziwej akcji użytkownika. Nowoczesne przeglądarki znacznie ograniczyły to zagrożenie, ale przy audytach wciąż trafiam na formularze i API, które da się w ten sposób wywołać. W tym wpisie pokazuję, jak zabezpieczam aplikacje przed CSRF warstwa po warstwie i jak sprawdzam, czy ochrona rzeczywiście działa.
Jak wygląda atak w praktyce
Użytkownik jest zalogowany w panelu sklepu. W innej karcie otwiera stronę atakującego, na której znajduje się ukryty formularz:
<form action="https://sklep.pl/konto/zmien-email" method="POST" id="f">
<input type="hidden" name="email" value="atakujacy@example.com">
</form>
<script>document.getElementById('f').submit();</script>
Jeśli sklep nie ma ochrony, przeglądarka wyśle żądanie razem z ciasteczkiem sesji, a adres e-mail na koncie zostanie zmieniony. Po chwili atakujący resetuje hasło i przejmuje konto. Atakujący nie widzi odpowiedzi serwera, ale do przejęcia konta wcale jej nie potrzebuje.
Narażone są wszystkie akcje zmieniające stan i uwierzytelniane ciasteczkiem: zmiana danych konta, zamówienia, usuwanie treści, a nawet samo logowanie. W tak zwanym login CSRF atakujący loguje ofiarę na swoje konto, żeby potem zobaczyć, co ta w nim zapisze.
Warstwy ochrony, które stosuję
| Warstwa | Co blokuje | Ograniczenia |
|---|---|---|
| Brak zmian stanu przez GET | ataki przez zwykły link lub obrazek | to warunek, a nie zabezpieczenie samo w sobie |
Atrybut SameSite ciasteczka sesji | wysyłanie ciasteczka w żądaniach z innych witryn | nie chroni przed żądaniami z subdomen tej samej witryny |
| Token CSRF | żądania, które nie pochodzą z formularza wygenerowanego przez aplikację | wymaga obsługi w każdym formularzu i w zapytaniach AJAX |
Sprawdzenie Origin lub Sec-Fetch-Site | żądania z obcych domen | trzeba poprawnie obsłużyć brak nagłówka w starszych klientach |
Żadna z tych warstw nie wystarcza sama. Najczęściej łączę token z SameSite=Lax i sprawdzeniem nagłówka Origin
przy zmianach o dużym znaczeniu, takich jak hasło czy adres e-mail.
Atrybut SameSite w praktyce
Ciasteczko sesji ustawiam z kompletem atrybutów:
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
- Strict: ciasteczko nie jest wysyłane w żadnym żądaniu z innej witryny, także po kliknięciu zwykłego linku. Użytkownik wchodzący z wyszukiwarki czy maila widzi się jako niezalogowany, dlatego Strict stosuję tylko w panelach administracyjnych.
- Lax: ciasteczko jest wysyłane przy przejściu linkiem, ale nie w żądaniach POST z innej witryny, ramkach ani zapytaniach wysyłanych w tle. To rozsądna wartość domyślna dla większości aplikacji.
- None: ciasteczko jest wysyłane zawsze i wymaga atrybutu
Secure. Potrzebne tylko przy osadzaniu aplikacji w ramkach na innych domenach lub przy logowaniu przez zewnętrzne usługi.
Chrome i Edge traktują ciasteczka bez atrybutu SameSite jak Lax, ale Firefox i Safari tego nie robią. Dlatego zawsze ustawiam
atrybut jawnie, zamiast polegać na domyślnym zachowaniu przeglądarki.
Token CSRF
Token to losowa wartość zapisana w sesji i dodawana do każdego formularza. Serwer porównuje ją z wartością z sesji, a strona atakującego nie
ma jak jej odczytać. Większość frameworków ma to wbudowane: w Laravelu wystarczy dyrektywa @csrf w formularzu, w Symfony
i Django ochrona jest włączona domyślnie. W formularzu wygląda to tak:
<form method="POST" action="/konto/zmien-email">
<input type="hidden" name="_token" value="{{ token z sesji }}">
...
</form>
W PHP bez frameworka generuję token funkcją bin2hex(random_bytes(32)), a porównuję funkcją hash_equals(), która
nie zdradza wyniku przez czas porównania. Przy zapytaniach AJAX przekazuję token w nagłówku, np. X-CSRF-Token, odczytując go
z meta tagu na stronie.
Sprawdzanie nagłówków Origin i Sec-Fetch-Site
Przeglądarki dołączają do żądań POST nagłówek Origin z domeną, z której pochodzą, a współczesne także Sec-Fetch-Site
z informacją, czy żądanie przyszło z tej samej witryny. Na serwerze odrzucam zmiany stanu, jeśli żaden z tych nagłówków nie wskazuje na moją
domenę:
$site = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? null;
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
if ($site !== null && !in_array($site, ['same-origin', 'none'], true)) {
http_response_code(403); exit;
}
if ($site === null && $origin !== null && $origin !== 'https://sklep.pl') {
http_response_code(403); exit;
}
API i tokeny w nagłówku Authorization
Gdy aplikacja uwierzytelnia żądania tokenem przesyłanym w nagłówku Authorization, a nie ciasteczkiem, CSRF przestaje być
problemem, bo przeglądarka nie dołącza takiego nagłówka sama. Token musi jednak być gdzieś przechowywany w przeglądarce, a wtedy każda luka
pozwalająca wykonać skrypt na stronie daje do niego dostęp. Dlatego przy takim podejściu priorytetem staje się
ochrona formularzy i kodu strony przed XSS. W większości aplikacji, które buduję, zostaję przy
ciasteczku HttpOnly i tokenie CSRF, bo skrypt na stronie nie ma wtedy dostępu do sesji.
Przy API z ciasteczkami pilnuję też konfiguracji CORS. Odpowiedź z Access-Control-Allow-Origin ustawionym na dowolną domenę
razem z Access-Control-Allow-Credentials: true otwiera drogę do odczytu danych zalogowanego użytkownika z obcej strony.
Jak sprawdzam ochronę przed CSRF
- Wyszukuję wszystkie akcje zmieniające stan i sprawdzam, czy żadna nie działa przez GET.
- Sprawdzam w narzędziach deweloperskich atrybuty ciasteczka sesji w zakładce Application.
- Tworzę lokalnie prosty plik HTML z formularzem wysyłającym żądanie do testowanej aplikacji i otwieram go w przeglądarce zalogowanej do konta testowego. Żądanie powinno zostać odrzucone.
- Powtarzam wysłanie formularza w Burp Suite lub Postmanie bez tokenu i ze zmienionym tokenem. Serwer powinien za każdym razem zwrócić błąd.
Ochrona przed CSRF to tylko jeden element zabezpieczenia kont. Równie ważne jest to, czy atakujący może zgadywać hasła, co blokują limity prób logowania, czyli podstawa ochrony przed atakami brute force, oraz to, jak hasła są zapisane w bazie, gdyby kiedyś doszło do wycieku. Tu podstawą jest bezpieczne przechowywanie haseł w bazie danych.