Wchodząc w świat Symfony niektóre rzeczy robimy z automatu, bo tak jest w dokumentacji. Nie zawsze zdajemy sobie sprawę z tego, o co tak na prawdę chodzi z pewnymi detalami. Jednym z takich detali jest token CSRF, który na pierwszy rzut oka wydaje się uciążliwy. Ale jest ważny, o czym będę pisał dzisiaj.

Kto zmienił moje hasło?

Wyobraźmy sobie, że stworzyliśmy bardzo popularny portal społecznościowy, z którego korzysta pół Polski. Nagle zaczynają nam sypać się maile od użytkowników, że ktoś im zmienił hasło. Naszym zadaniem jest pokorne przyjrzenie się logom, najpierw jednego użytkownika, potem drugiego. Patrzymy na adres IP logowania do platformy i widzimy, że… logował się wyłącznie właściciel konta. Przeglądamy logi serwera, sprawdzamy zabezpieczenia bazy danych, szukamy dziury. Po kilku dniach poszukiwań wychodzi na to, że wszystko jest git. W takim razie dlaczego coraz to większa ilość użytkowników dostaje blokady konta? O co w ogóle chodzi?

Dziura w platformie jednak była. Okazuje się, że proste zadanie zmiany hasła zlecone osobie o mniejszym doświadczeniu zawiodło. Brakowało weryfikacji tokena CSRF. No ale… dlaczego jest on taki ważny?

Czym są ataki CSRF?

Powyższa scenka dotyczy ataku CSRF (ang. Cross-Site Request Forgery). Polega to na wykonaniu operacji na niezabezpieczonym endpoincie przy wykorzystaniu naszej sesji. Możemy doświadczyć takiego ataku na wiele różnych sposobów:

  • Odbierając szkodliwego maila
  • Przeglądając źle zabezpieczony serwis internetowy
  • Klikając w link, co do którego wiarygodności nie mamy pewności

Pomimo różnej metody wejścia, każdy z ataków wygląda tak samo: będąc zalogowanym do serwisu, którego dotyczy atak – przeglądarka uruchamia spreparowany link do tego serwisu, który np. zmienia nam maila lub hasło. Celem ataku nie musi być zabranie konta użytkownikowi. Może chodzić na przykład o zasypanie portalu niechcianymi treściami. Chociaż ja wyobrażam sobie scenariusz, gdzie moglibyśmy stracić zyski ze sprzedaży w aplikacji e-commerce, bo przez tego typu atak ktoś zmienił numer konta, na który lecą pieniądze za opłacone zamówienia.

Jak wygląda przykładowy atak CSRF?

W takiej sytuacji najlepiej jest przedstawić jakiś prosty, nawet w sumie mało prawdopodobny, ale dobrze obrazujący przykład. Zatem: wyobraźmy sobie, że chodzi o zmianę adresu e-mail. Załóżmy, że tego typu funkcjonalność pisała osoba mało kompetentna, która zrobiła to za pomocą żądania GET (w zasadzie może być każda inna metoda), które może wyglądać następująco:

GET /email/change HTTP/1.1
Host: vulnerable-website.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 30
Cookie: session=yvthwsztyeQkAPzeQ5gHgTvlyxHfsAfE
email=enemy@example.com

Następnie my, jako użytkownik, logujemy się do serwisu i sobie z niego korzystamy. Dalej nasz „znajomy” wysyła nam link do „obrazka” w internecie. Albo nawet znaleźliśmy taki link na tym serwisie, na który się zalogowaliśmy. Nie wiemy jednak, że po kliknięciu w link pokaże nam się obrazek o kodzie:

<img src="http://vulnerable-website.com/email/change?email=enemy@example.com" width="0" height="0" />

Po otworzeniu linka widzimy całe nic, wiec zamykamy zakładkę. Korzystamy sobie dalej z komputera, następnie na koniec zabawy wylogowujemy się z zaatakowanego serwisu. Ponieważ serwis nie miał zbyt dobrych zabezpieczeń – nie możemy zalogować się ponownie.

Prosty i niepozorny token załatwia sprawę 🙂

Zanim przejdziemy do zabezpieczenia tokenem, chciałbym nadmienić, że brakowało tutaj stanowczo więcej zabezpieczeń:

  • Do użytkownika powinna pójść notyfikacja (mail / sms), że postanowiono zmienić adres e-mail do konta.
  • Użytkownik powinien otrzymać link, który powinien potwierdzić zmianę.
  • Do zmiany stanu systemu nie stosuje się metody GET. Z jednej strony to nie załatwi sprawy, ale ograniczy trochę pole rażenia. Chociaż w sumie do wysyłania POSTa / PUTa atakujący może wykorzystać JS więc…
  • No i oczywiście: brakowało weryfikacji tego tokena CSRF.

Z jednej strony token CSRF brzmi jak coś bardzo zaawansowanego. Na pewno jest to coś generowane za pomocą jakiegoś klucza, jakoś specjalnie zabezpieczone. I na pewno jest to skomplikowane do zrozumienia. Na szczęście dla nas wszystkich: koncept tokena CSRF jest ultra prosty.

Cała zabawa z tokenem polega na tym, że podczas wyświetlania formularza generujemy jakąś losową wartość, którą następnie wsadzamy w sesję. Następnie podczas submitu formularza oczekujemy dokładnie tej wartości podanej w formularzu. Atakujący nie będzie miał prawa znać tej wartości, bo to jak zabawa w „zgadnij, o jakiej liczbie właśnie pomyślałem”. Ot cała tajemnica 🙂

Czy wszędzie potrzebujemy tokenów CSRF?

Specyfika ataków CSRF polega na tym, że ktoś coś chce zmienić w systemie. Dlatego powinniśmy zabezpieczyć wszystkie te miejsca, które zmieniają stan aplikacji. Mowa tu zarówno o tak istotnych funkcjonalnościach jak zmiana hasła czy adresu e-mail konta, jak i dodawanie produktu do koszyka czy publikowanie treści w serwisie społecznościowym. Bo każdy z wymienionych przypadków zmienia stan aplikacji.

Dobrze by było również, aby każda zmiana stanu w systemie była obsługiwana jako formularz w aplikacji. Bo jeżeli mamy link, po kliknięciu którego „coś się dzieje”, to wtedy mamy sytuację ze scenariusza z początku wpisu. Można by próbować przekazywać token przez query linku, ale to trochę jak robienie rzeczy dookoła.

Weryfikacji tokena CSRF nie potrzebujemy jednak wszędzie tam, gdzie mamy formularze nie powodujące zmiany w systemie. Może to być formularz wyszukiwarki produktów, czy przycisk do porównywarki produktów czy filtrowanie listy użytkowników w panelu administracyjnym.

Jak robi to Symfony?

Tak się składa, że obsługa tokenów CSRF jest wspierana natywnie w formularzach Symfony. Dokumentację na temat zabezpieczenia tokenem znajdziecie pod tym linkiem. Możemy włączyć/wyłączyć opcję generowania tokena globalnie dla wszystkich formularzy poprzez ustawienie odpowiedniej wartości klucza framework.csrf_protection.

Możemy również skonfigurować obsługę tokena CSRF dla każdego formularza z osobna, niezależnie od ustawień globalnych:

<?php

namespace App\Form;

use Symfony\Component\OptionsResolver\OptionsResolver;

class MyType extends AbstractType
{
    // ...

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'csrf_protection' => true,
            'csrf_field_name' => '_token',
            'csrf_token_id'   => 'task_item',
        ]);
    }

    // ...
}

Dobrą praktyką jest konfiguracja osobnego identyfikatora tokena (csrf_token_id) dla każdego formularza. Ta praktyka jest szczególnie przydatna, jeżeli mamy na stronie wiele formularzy.

Dalej w widoku formularza, jeżeli generujemy osobno każde pole formularza, musimy wrzucić następującą linijkę:

<div class="some-div">
    {{ form_start(form) }}
    {{ form_row(form.firstName) }}
    {{ form_row(form.lastName) }}
    {{ form_row(form._token) }}  <!-- The token generation line -->
    {{ form_end(form, {'render_rest': false}) }}
</div>

A jeżeli nie chcemy bawić się zabawkami Symfony Forms, to możemy zadziałać tak, jak mówi dokumentacja Symfony:

<form action="{{ url('admin_post_delete', { id: post.id }) }}" method="post">
    {# the argument of csrf_token() is an arbitrary string used to generate the token #}
    <input type="hidden" name="token" value="{{ csrf_token('delete-item') }}">

    <button type="submit">Delete item</button>
</form>

No ale jeżeli chcemy porzucić formularze Symfony, to musimy się liczyć z tym, że będziemy musieli samodzielnie walidować token po submicie formularza.

Poszperajmy w vendorze na koniec… 🙂

Jeżeli macie ochotę na analizę kodu, to mam dla Was kilka miejsc wartych uwagi. Pierwszym będzie konfiguracja CSRF zawarta FrameworkExtension. Możemy zauważyć tam, że konfiguracja tokena jest sprzężona bezpośrednio z sesjami oraz istnieniem klasy CsrfTokenManager z innej paczki Symfony – symfony/security-csrf. A pod tym linkiem możecie zobaczyć konfigurację CSRF w kontekście Symfony Forms.

A już zupełnie na koniec polecam Wam przeglądnięcie paczki symfony/security-csrf. Możecie tam zobaczyć dwie rzeczy: pierwsza, że generowanie tokena to jest na prawdę generowanie losowej wartości:

<?php

// https://github.com/symfony/security-csrf/blob/7.0/TokenGenerator/UriSafeTokenGenerator.php

namespace Symfony\Component\Security\Csrf\TokenGenerator;

// ...

class UriSafeTokenGenerator implements TokenGeneratorInterface
{
    // ...

    public function generateToken(): string
    {
        // Generate an URI safe base64 encoded string that does not contain "+",
        // "/" or "=" which need to be URL encoded and make URLs unnecessarily
        // longer.
        $bytes = random_bytes(intdiv($this->entropy, 8));

        return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
    }
}

Oraz druga rzecz: domyślne, oraz w zasadzie jedyne zdefiniowane w tej paczce CSRF Storage to są storage sesji: https://github.com/symfony/security-csrf/tree/7.0/TokenStorage.

Profesjonalista, zajmujący się na co dzień aplikacjami biznesowymi w ekosystemie PHP. Jego pasją jest odkrywanie nowych konceptów programistycznych oraz wzorce architektoniczne. Uwielbia również pisać testy, gdyż jak sam uważa, dobry kod to przetestowany kod.

Comments are closed.