Logo David Burdelak
Cybersecurity

Ochrona przed atakami CSRF – jak zabezpieczyć akcje użytkowników i ciasteczka?

Zabezpieczanie aplikacji www i ciasteczek przed atakami typu Cross-Site Request Forgery (CSRF)

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ę

WarstwaCo blokujeOgraniczenia
Brak zmian stanu przez GETataki przez zwykły link lub obrazekto warunek, a nie zabezpieczenie samo w sobie
Atrybut SameSite ciasteczka sesjiwysyłanie ciasteczka w żądaniach z innych witrynnie 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 domentrzeba 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

  1. Wyszukuję wszystkie akcje zmieniające stan i sprawdzam, czy żadna nie działa przez GET.
  2. Sprawdzam w narzędziach deweloperskich atrybuty ciasteczka sesji w zakładce Application.
  3. 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.
  4. 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.