Zabezpiecz formularz przed spamem


Poznaj skuteczny sposób zabezpieczenia się przed botami i spamem na swoim formularzu


Formularze mają do siebie to, że są podatne na ataki różnych botów czy hackerów. Słyszałem ciekawe stwierdzenie: Ludzie dzielą się na tych, którzy robią kopie zapasowe i na tych, którzy będą je robili. Moim zdaniem podobnie jest właśnie z formularzami. Osoby pracujące z formularzami dzielą się na tych którzy walczyli ze spamem i tych którzy będą z nim walczyć. Przeczytaj poniższy wpis i dowiedz się jak skutecznie zabezpieczyć swój formularz przed spamem.

Poznaj swojego wroga

Z formularzy korzystają osoby, które tego potrzebują, boty ale zdarzają się również osoby, których jedyn celem jest zasypanie nas spamem, czyli hackerzy. Ci ostatni są przebiegli i przyznam, że czasem podziwiam ich kreatywność. Prywatnie nie miałem dużo do czynienia ze spamem ale w pracy to co innego. Tworzyłem sporo formularzy. Ruch na nich był różny, od kilku dziennie po takie które były wysyłane częściej niż raz na sekundę. Formularze mogą przybierać różne formy. Jedną z nich są na przykład gwiazdki lub Lubię to na facebook. To jest najczęściej ukryty formularz z przygotowanym hash, który jest wysyłany po wciśnięciu odpowiedniego przycisku. Hackerzy. Wszystkich ataków jest cała masa. Ja chciałbym skupić się na atakach, które można określić jako spam, czyli najprościej niechciane wiadomości.

Playback bots

Ten rodzaj ataków polega na zapisaniu danych które są wysyłane w formularzu. Osoby atakujące najpierw korzystają z formularza i jeśli uda im się go poprawnie wysłać zapisują jakie pola zostały wysłane. Ten formularz może być następnie wysłany z różnymi danymi ale struktura pozostaje identyczna.

Na taki rodzaj ataków skuteczne może być zmienianie nazw pól. Po weryfikacji, że ktoś nas atakuje możemy zmienić kilka nazw pól przez co nagrane formularze przestaną być poprawne. Jest to bardzo uciążliwe i wymaga ciągłego monitorowania i reagowania na ewentualny spam.

Można też próbować zabezpieczyć się w bardziej zautomatyzowany sposób. Jeśli mamy taką możliwość to dobrze gdybyśmy dodali do naszych formularzy dynamiczne nazwy zmiennych bez naszego udziału. Jeśli nie mamy takiej możliwości albo wiąże się to z dużą przebudową formularzy na stronie, to można dodać chociaż jedno ukryte pole o zmiennej nazwie. Innym rozwiązaniem, które łatwiej jest dodać jest stempel czasowy. Jeśli przekażemy do formularza aktualną datę do jakiegoś ukrytego pola to następnie przy odczytaniu danych łatwo sprawdzimy czy formularz jest wysłany na "świeżo" czy jest to zarejestrowany wcześniej formularz. Warto zadbać, żeby taka data była zaszyfrowana żeby atakującemu trudniej było rozpoznać co dokładnie przechowujemy w tym polu.

Form filling bot

To jest ten rodzaj ataków których nie znoszę. Idea polega na rozpoznaniu pól wyrenderowanych na formularzu, szybkim ich wypełnieniu i wysłaniu. Najczęściej spotkałem się z botami, które nie były w stanie rozpoznać pól i wypełniamy wszystkie pola po kolei. Są to skrypty automatyczne i uniwersalne. Niestety bardzo dużo formularzy nie jest na to przygotowana i dlatego takie ataki są dalej popularne.

Popularną i skuteczną metodą na taki atak może być HoneyPot . Polega On na umieszczeniu pola widocznego dla użytkownika z podpisem, żeby ten go nie wypełniał. Następnie sprawdzamy czy te pole jest puste. Jeśli nie to najczęściej oznacza to atak. Jeśli dodatkowo ukryjemy te pola dla użytkownika, na przykład przez dodanie odpowiedniego tła, przykrycie innym elementem lub po prostu schowamy to css to normalni użytkownicy nie zauważą różnicy.

Personalized form filling bot

Tu dochodzimy do ataków nieco bardziej zaawansowanych. Wcześniej omówiłem "głupie" boty, który wypełniamy formularz "na ślepo". Łatwo jest natomiast zmodyfikować taki skrypt jeśli znamy strukturę formularza na stronie. I tak jeśli ktoś zauważy, że jego automat do ataków jest nieskuteczny może wejść na stronę a tam w strukturze HTML może już zauważyć nasze pole HoneyPot. Jeśli się zorientuje to wystarczy, że doda pola do wykluczenia i nasz formularz ponownie jest podatny na atak. Tym razem jednak na jeden konkretny.

Tu może pomóc nowe ukryte pole. Przy pomocy JavaScript zwiększamy wartość tego pola w sekundowych interwałach. Tworzymy swoisty stoper. W backend sprawdzamy wartość tego pola. Jeśli ta wartość jest mniejsza niż czas jaki oszacowaliśmy na wysłanie formularza wiemy, że to atak. Zadaniem botów jest bowiem wysłanie formularza tak szybko jak to możliwe. Przy 10 polach na formularzu to nie ma możliwości, żeby ktoś wysłał go szybciej niż w 5-10 sekund przy normalnym uzupełnieniu.

Oczywiście to rozwiązanie nie jest idealne. Jeśli jest to atak spersonalizowany to atakujący ma dwie możliwości. Pierwszą jest odczekanie określonego czasu co zasymuluje normalnego użytkownika. W ten sposób niestety nie rozpoznamy czy to bot czy nie i przepuścimy dane. Ale czy to jest w zupełności złe? Wcześniej ktoś mógł nas zasypywać danymi częściej niż raz na sekundę. Teraz mamy pewność, że jeden formularz będzie wysłany przynajmniej w 10 sekundowych odstępach. Jeśli natomiast atakujący zna się na rzeczy to łatwo zorientuje się, że zwiększamy konkretne pole i poprawi skrypt tak, że przed wysłaniem sam ustawi jego wartość na taką, którą przepuści formularz. I tu niestety dużo nie możemy zrobić. Jeśli dobry hacker się uprze, to złamie większość naszych zabezpieczeń. Jedyne co możemy zrobić to go spowolnić lub dać więcej zagadek licząc, że zabawa przestanie mu się opłacać.

Ludzie

Mówię tu głównie o ludziach którzy korzystają z formularza poprawnie. Nie możemy nakładać takich zabezpieczeń, które spowodują  że zwykły użytkownik nie da rady wysłać formularza, bo będzie na przykład zasypywany milionami krokami walidacji. Tak niestety jest z captchą. Można wiele mówić na temat jej skuteczności i słuszności ale wszyscy z którymi rozmawiałem są zgodni, że Captcha potrafi być uciążliwa i nie raz powodowała, że osoby zrezygnowały z wysłania formularza właśnie przez nieczytelne obrazki. Z drugiej strony mamy też ludzi, którzy manualnie wysyłają zgłoszenia. Tu niestety nic nie możemy zrobić. Taki użytkownik nie pisze botów a jedynie ewentualne skrypty, które ułatwią mu pracę. Większość akcji i tak wykonuję, lub przynajmniej monitoruje ręcznie. Miałem tak nawet niedawno gdzie na konkursie dziewczyna wysyłała pracę przez prawie godzinę co mniej więcej minutę. Z jednej strony tu nie możemy za dużo zdziałać ale z drugiej taki użytkownik nie wygeneruje aż tyle spamu co specjalnie przygotowane boty.

Captcha?

Część osób zabezpiecza formularze właśnie przez Captchę. Dlaczego więc nie pójść ich śladem? Captcha jest bardzo skuteczna ale niestety nie ma rozwiązań bez wad. Wspomniałem wcześniej że bywa frustrująca i to niestety jej największa wada. Wg niektórych badań potrafi zmniejszyć konwersję o mniej więcej 3.2%. A to już jest sporo. Zdarzają się takie obrazki, że nic nie widać. Najgorzej jeśli dostaniemy kilka obrazków pod rząd. Nawet niedawno próbowałem coś zamówić i Captcha tak się na mnie uwzięła, że miałem z 3 obrazki a najgorsze, że po zaznaczeniu te zaczynały znikać co jeszcze bardziej mnie frustrowało. Przez to wszystko byłem o włos o rezygnacji z kupna gry Robinsona Cruiso. Swoją drogą to byłaby spora strata, bo gra jest rewelacyjna. Poza tym nie zawsze da się captchę zastosować. Wyobraźcie sobie bowiem głosowanie w formie gwiazdek pod artykułem lub polubienia na facebooku, po których musicie zaznaczać obrazki. No nie. Po prostu się nie da ;)

Rejestracja i logowanie

Skupmy się teraz na gwiazdkach i polubieniach. Bo to proste formularze, które można wysłać tak naprawdę w mniej niż kilka sekund. Ciężko jest tu dodać walidację. Skuteczny sposób to dodanie rejestracji i opcji głosowania tylko dla zalogowanych. Podobnie możemy również zrobić z mniejszymi formularzami. Dostęp tylko dla zalogowanych to bardzo skuteczny sposób. Łatwo sprawdzić czy ktoś już wysłał dany formularz. Bez tego musimy zapisywać dane w sesji lub cookies. A to niestety łatwo wyczyścić. Niestety nie zawsze możemy wprowadzić takie rozwiązanie. Wyobraźmy sobie rejestrację przed wysłaniem zapytania na formularzu kontaktowym. Nie tylko botów ale i ludzi by tam brakowało ;)

Zabezpiecz się

Skoro już znamy wroga i jego możliwości spróbujmy z nim powalczyć. Udostępniam stronę z podglądem formularzy, żebyście mogli to sami sprawdzić. Przedstawiam tam 6 formularzy. Pierwszy jest niezabezpieczony a pięć kolejnych mają zaimplementowane wcześniej wymienione metody zabezpieczeń przed spamem. Wszystkie z tych rozwiązań były przeze mnie wykorzystywane. Ostatnie to kombinacja i moje autorskie rozwiązanie, które planuję uporządkować i w przyszłości wrzucić na GitHub na OpenSource. Przed wdrożeniem tego rozwiązania musiałem walczyć ze spamem w postaci blisko 200 tysięcy głosów dziennie. Od momentu wprowadzenia tego rozwiązania jeszcze nie mieliśmy takiego problemu. Niestety nie jestem w stanie powiedzieć czy to sam mechanizm czy może popularność akcji wpływa na ten wynik. Może wtedy trafił się ktoś bardzo uparty? Jedno jest pewne. Te rozwiązanie nie jest bardzo skomplikowane i nie zaszkodzi jak dowiemy się jak je dodać a w przyszłości może po prostu zainstalować ;)

Zanim przejdziemy do omawiania poszczególnych zabezpieczeń zobaczmy jak będzie wyglądać nasz podstawowy formularz bez żadnych zabezpieczeń.

Formularz będzie bardzo prosty.

class SimpleForm(forms.Form):
    text = forms.CharField()

Teraz widok. Skorzystam z generycznych widoków Django, w którym nadpiszę metodę form_valid . Domyślnie przekierowuje ona na konkretny adres. My jednak wyrenderujmy ponownie ten sam szablon.

class SimpleFormView(FormView):
    '''Formularz bez zabezpieczeń.'''
    template_name = 'form.html'
    form_class = SimpleForm

    def form_valid(self, form):
        return self.render_to_response(self.get_context_data(form=form))

Mamy formularz i widok. Został zatem szablon.

{% extends 'base.html' %}

{% block content %}
    {% if form.is_valid %}
        Dziękujemy! Zgłoszenie zostało wysłane
    {% else %} 
        <form method="post" action=".">
            {% csrf_token %}
            {{ form }}
	    <button type="submit">Wyślij</button>
        </form>
    {% endif %}
{% endblock %}

Formularz z HoneyPot

Do opisu odsyłam tutaj . Zasada działania jest bardzo prosta. Jedyną zmianą będzie dodanie walidacji pola w formularzu.

class HoneyPotForm(forms.Form):
    text = forms.CharField()
    honeypot = forms.CharField(label=u"Zostaw te pole puste")

    def clean_honeypot(self):
        if self.cleaned_data.get('honeypot'):
            raise ValidationError(u'Zostaw pole PUSTE')

Podczas walidacji pola honeypot sprawdzamy czy jest puste. Jeśli nie to najprawdopodobniej był to bot. Napiszmy zatem widok, który będzie dziedziczyć z przygotowanego wcześniej SimpleFormView i zmieni klasę formularza, którą ma obsługiwać.

class HoneyPotFormView(SimpleFormView):
    '''Formularz z zabezpieczeniem w postaci honeypot.'''
    form_class = HoneyPotForm

W ten sposób wspólnie stworzyliśmy pierwszy formularz, który jest odporny na ślepe automaty wysyłające spam. W naszym przykładzie pole jest widoczne. Można je ukryć, aby formularz był bardziej UserFriendly. Oczywiście jak ktoś wejdzie na stronę to od razu się zorientuje, że nie może wypełniać tego pola i łatwo skoryguje skrypt o stosowne wykluczenia. Spróbujmy zatem pójść krok dalej.

Walidacja przez JavaScript na front

Do opisu odsyłam tutaj . Zacznijmy może od widoku, bo będzie równie analogiczny do wcześniejszego.

class JSTimeFormView(SimpleFormView):
    '''Formularz z zabezpieczeniem w postaci inkrementacji zmiennej w JS'''
    form_class = JSTimeForm

Podobnie jak wcześniej skorzystalismy z gotowej klasy. Przejdźmy zatem do serca z logiką czyli formularza.

class JSTimeForm(forms.Form):
    MIN_TIME = 5  # 5 sec
    MAX_TIME = 600  # 10 min

    text = forms.CharField()
    time = forms.IntegerField(widget=forms.HiddenInput(attrs={'class': 'time'}))

    def clean_time(self):
        time = self.cleaned_data.get('time', 0)
        if time < self.MIN_TIME:
            raise ValidationError(u'Zwolnij kowboju! ;)')

        if time > self.MAX_TIME:
            raise ValidationError(u'Formularz wygasł. Przeładuj stronę')
        
        return time

Do pola time dodaliśmy widget, który ukryje nasze pole w HTML oraz klasę time , dzięki której będziemy mogli łatwo złapać obiekt w szablonie. Logika jest stosunkowo prosta. Pobieramy wartość naszego pola i sprawdzamy czy mieści się w wybranych zakresie. W naszym przypadku jest to od 3 do 10 sekund. Stosunkowo mało ale pamiętajmy, że formularz ma tylko 1 pole :)

A teraz gwóźdź programu, czyli nasz JavaScript. Właśnie przy jego użyciu będziemy zwiększać wartość naszego pola w sekundowych odstępach:

    function init(){
        let elements = document.getElementsByClassName('time');
        for (var i = 0; i < elements.length; i++) {
            elements[i].setAttribute('value', 0);
        }
    }

    function increment(){
        let elements = document.getElementsByClassName('time');
        for (var i = 0; i < elements.length; i++) {
            elements[i].setAttribute('value', parseInt(elements[i].getAttribute('value')) + 1);
        }
    }
    document.addEventListener("DOMContentLoaded", function() {
        init();
        setInterval(increment, 1000);
    })

Po prostu pobieramy wszystkie obiekty o wspomnianej wcześniej klasie time i nakładamy listener, który w sekundowych odstępach będzie zwiększać ich wartość. Stosunkowo proste a zabezpieczymy się przed kolejną grupą botów. Teraz żeby wysłać jakiś formularz ktoś musi chociaż odrobinę znać się na JavaScript. Łatwo zauważyć taki schemat i w botach dodać mechanizm ręcznego przestawiania tego pola na wartość, która będzie poprawna podczas walidacji formularza.

Walidacja daty w backend.

Do pełnego opisu odsyłam tu . Do tej pory zabezpieczaliśmy się przed tak zwanymi crawlerami, czyli robotami którzy wysyłają formularze z frontu. Jesteśmy jednak w całości podatni na wspomniany wcześniej atak typu PlayBack Record. Rozwiązaniem może być przekazanie do szablonu stempla czasowego. To powinno po części rozwiązać nasz problem. Widok podobnie jak wcześniej będzie bardzo prosty

class BackendTimeFormView(SimpleFormView):
    '''Formularz z zabezpieczeniem w postaci walidacji daty na backend.'''
    form_class = BackendTimeForm

Przejdźmy teraz do formularza.

class BackendTimeForm(forms.Form):
    MIN_TIME = 5  # 5 sec
    MAX_TIME = 600  # 10 min

    text = forms.CharField()
    backendtime = forms.DateTimeField(widget=forms.HiddenInput(), required=True)

    def __init__(self, *args, **kwargs):
        super(BackendTimeForm, self).__init__(*args, **kwargs)
        self.fields['backendtime'].initial = datetime.now()

    def clean_backendtime(self):
        backendtime = self.cleaned_data.get('backendtime')
        current = datetime.now()
        time = current - backendtime

        if time.seconds < self.MIN_TIME:
            raise ValidationError(u'Zwolnij kowboju! ;)')

        if time.seconds > self.MAX_TIME:
            raise ValidationError(u'Formularz wygasł. Przeładuj stronę')

Tworzymy nowe, ukryte pole backendtime, któremu w metodzie __init__ ustawiamy domyślną wartość. Wartość te mogłem ustawić już przy inicjalizacji pola, jednak celowo zrobiłem to w metodzie. Niech będą do podwaliny do dalszych rozwiązań.

Walidacja również nie jest skomplikowana. Pobieramy datę z formularza i sprawdzamy jej różnicę z aktualną datą. Jeśli będzie mniejsza od zera lub spoza zdefiniowanego zakresu uznajemy to za spam. Niestety przez to, że przechowujemy dane w postaci jawnej to łatwo domyślić się, że jest to nasz stempel czasowy i szybko można to obejść, Zróbmy to zatem lepiej :)

Stempel czasowy w postaci hash

Do pełnegeo opisu odsyłam tu . Ten mechanizm będzie poprawioną wersją poprzedniego rozwiązania. Wszystko będzie odbywać się identycznie z tą różnicą, że data nie będzie przechowywana w postaci jawnej a w postaci zaszyfrowanego hasha.

Zanim przejdziemy dalej musimy zapoznać się z mechanizmem szyfrowania. Ja skorzystałem z gotowego AES na StackOverflow z drobnymi przeróbkami pod własne potrzeby. Dodałem na przykład rekurencję, która będzie potrzebna nam później. Dodajmy zatem plik cihper.py

import hashlib
from Crypto import Random
from Crypto.Cipher import AES

BS = 32


class AESCipher(object):

    def __init__(self, key):
        self.key = hashlib.sha256(key.encode()).digest()

    def encrypt(self, raw, repeated=1):
        repeated -= 1
        if repeated > 0:
            raw = self.encrypt(raw, repeated)

        raw = pad(raw)
        iv = Random.new().read(AES.block_size)
        cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return b64.b64encode(iv + cipher.encrypt(raw)).decode('utf-8')

    def decrypt(self, enc):
        enc = b64.b64decode(enc)
        iv = enc[:AES.block_size]
        cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return unpad(cipher.decrypt(enc[AES.block_size:])).decode('utf-8')

Skoro już mamy mechanizm do szyfrowania dodajmy nasz widok

class HashedBackendTimeFormView(SimpleFormView):
    '''Formularz z zabezpieczeniem w postaci walidacji zaszyfrowaniej daty na backend.'''
    form_class = HashedBackendTimeForm

I ponownie logika w fomularzu

from django.utils.dateparse import parse_datetime
from ddeby.cipher impotr cipher

KEY = "My Secret Key"

cipher = AESCipher(KEY)


class HashedBackendTimeForm(forms.Form):
    MIN_TIME = 5  # 5 sec
    MAX_TIME = 600  # 10 min

    text = forms.CharField()
    backendtime = forms.CharField(widget=forms.HiddenInput(), required=True)

    def __init__(self, *args, **kwargs):
        super(HashedBackendTimeForm, self).__init__(*args, **kwargs)
        self.fields['backendtime'].initial = cipher.encrypt(str(datetime.now()))

    def clean_backendtime(self):
        encrypted = cipher.decrypt(self.cleaned_data.get('backendtime'))
        form_date = parse_datetime(encrypted)
        current = datetime.now()
        time = current - form_date

        if time.total_seconds() < self.MIN_TIME:
            raise ValidationError(u'Zwolnij kowboju! ;)')

        if time.total_seconds() > self.MAX_TIME:
            raise ValidationError(u'Formularz wygasł. Przeładuj stronę')

Pierwszą różnicą jest inny typ pola backendtime. Teraz nie będzie tam przechowywana data tylko zaszyfrowany tekst. W związku z tym musieliśmy zmienić typ pola. Następnie w metodzie __init__ ustawiamy wartość domyślną tego pola, którą jest właśnie zaszyfrowana data. Na końcu przed walidacją pobieramy odpowiednią wartość, deszyfrujemy ją i przy użyciu parse_datetime z django.utils tworzymy datę. Cała reszta odbywa się dokładnie tak jak wcześniej. Taki prosty zabieg pozwolił nam kilkukrotnie zwiększyć bezpieczeństwo naszego formularza.

Zmienne nazwy pól

To jest moje autorskie rozwiązanie. Do pełnego opisu odsyłam tu . W dużym uproszczeniu chodzi o połączenie poprzednich rozwiązań dodając element, który jeszcze bardziej utrudni ataki czyli właśnie zmienne nazwy pól. Nie będziemy zmieniać wszystkich nazw a jedynie te, które są nasza dodatkową walidacją.

Widok będzie się trochę różnić, bo musimy nadpisać szablon. Szablon ten będzie potrzebny do napisania niestandardowego JavaScript.

class SecureFormView(SimpleFormView):
    '''Formularz z mieszamą walidacją.'''
    form_class = SecureForm
    template_name = 'secure_form.html'

Skoro mamy widok, to podobnie jak wcześniej przejdźmy do formularza

from django.utils.dateparse import parse_datetime
from ddeby.cipher impotr cipher

KEY = "My Secret Key"

cipher = AESCipher(KEY)


class SecureForm(forms.Form):
    THRESHOLD = 2  # 2 sec
    MIN_TIME = 5  # 5 sec  
    MAX_TIME = 600  # 10 min

    text = forms.CharField()
    spin = forms.CharField(widget=forms.HiddenInput(), required=True)

    def __init__(self, *args, **kwargs):
        super(SecureForm, self).__init__(*args, **kwargs)
        # set spin initial value
        double_encrypted = self.data.get('spin') or cipher.encrypt(str(datetime.now()), 2)
        self.fields['spin'].initial = double_encrypted

        # set custom field
        self.field_name = cipher.decrypt(double_encrypted)
        self.fields[self.field_name] = forms.CharField(
            widget=forms.HiddenInput(), initial=self.data.get(self.field_name, 0))

    def clean_spin(self):
        double_encrypted = self.cleaned_data['spin']
        encrypted, time_delta = self.__check_time(double_encrypted)
        decrypted, backendtime_delta = self.__check_backendtime(encrypted)
        self.__deltas_compare(backendtime_delta, time_delta)
        return decrypted

    def __deltas_compare(self, backendtime_delta, time_delta):
        if abs(int(backendtime_delta - time_delta)) < self.THRESHOLD:
            raise ValidationError(u'Nie kombinuj')

    def __check_time(self, double_encrypted):
        encrypted = cipher.decrypt(double_encrypted)

        try:
            time = int(self.data.get(encrypted))
        except (ValueError, TypeError):
            raise ValidationError(u'Włącz obsługę JavaScript')

        if time < self.MIN_TIME:
            raise ValidationError(u'Zwolnij kowboju! ;)')

        if time > self.MAX_TIME:
            raise ValidationError(u'Formularz wygasł. Przeładuj stronę')

        return encrypted, time

    def __check_backendtime(self, value):
        date_str = cipher.decrypt(value)

        current_date = datetime.now()
        form_date = parse_datetime(date_str)
        delta = current_date - form_date

        if delta.total_seconds() < self.MIN_TIME:
            raise ValidationError(u'Zwolnij kowboju! ;)')

        if delta.total_seconds() > self.MAX_TIME:
            raise ValidationError(u'Formularz wygasł. Przeładuj stronę')

        return date_str, delta.total_seconds()

Co tu się tak właściwie dzieje? Naszym głównym polem jest pole spin. W nim będziemy przechowywać najważniejsze informacje, czyli podwójnie zaszyfrowaną datę.
Przeanalizujmy po kolei kod. Tu metoda __init__ jest już nieco bardziej rozbudowana. Po pierwsze łapiemy wartość z poprzedniego wysłania formularza z pola spin, lub dwukrotnie szyfrujemy aktualną datę. Dlaczego akurat dwukrotnie? W celu lepszego zabezpieczenia formularza ;). Przyjęty przez nas algorytm szyfrowania, szyfrując tekst za każdym razem da inny wynik. Nam zależy na powtarzalności. Jak zatem ją osiągnąć skoro wynik za każdym razem jest inny? No właśnie za pomocą podwójnego szyfrowania. Łatwiej zrozumiemy do na przykładzie. Czyli przykład szyfrowania kilka razy tej samej wiadomości

message = u'Ala ma kota'
cipher.encrypt(message)  # rU62/06x8H1JnEWX41kb5hAYFdhBliCWGGoerIayif//ELqTVTEhV5mcdnHgPQLj
cipher.encrypt(message)  # Erg7fCA642Dm/0AMQmc8lA0rz4Uxoi0XtWcv73JHZbS/5k338kXagdvs01vs4WwS
cipher.encrypt(message)  # DaLsS20B5clwZ9JxcbQxv8aTJ82VrbxeOReC9S7+YzHwvN3JztDphb+K/4sPpnah

Widzimy tutaj, że 3 razy szyfrowaliśmy tekst Ala ma kota i za każdym razem zaszyfrowany ciąg wyglądał inaczej. Jak osiągnąć powtarzalność?

message = u'Ala ma kota'
encrypted = cipher.encrypt(message)  # H6wJ05ScLVo6FoSkc3S484jAsq/ZVpX5MDxiVT1Rf6n/Js+4lui1eBBwy0gaXUpg
double_encrypted = cipher.encrypt(encrypted) # ijUg5IeshimF4TMzQWiOk7FNNIsv7ITH4Xr+z6invUZYDL0TvYeUiL0hdaQYVb4L...
decrypted = cipher.decrypt(double_encrypted)  # H6wJ05ScLVo6FoSkc3S484jAsq/ZVpX5MDxiVT1Rf6n/Js+4lui1eBBwy0gaXUpgL
decrypted_twice = cipher.decrypt(decrypted)  # u'Ala ma kota'

Jako, że wynik podwójnego szyfrowania jest 64 bitowy, dla czytelności wyświetliłem jedynie 32 pierwsze bity. Skoro wiemy, że szyfrując jakiś tekst za każdym razem wynik będzie inny ale deszyfrując ponownie otrzymamy ten sam tekst, możemy łatwo zapamiętać ciąg, niepowtarzalny dla innych ciąg, który dla nas będzie jednocześnie łatwy do przechowania.  W naszej aplikacji dodałem rekurencję, żeby kod był bardziej kompaktowy. Dzięki temu zamiast dwa razy wykonywać metodę encrypt wystarczy, że dodamy w drugim parametrze ile razy chcemy zaszyfrować nasz ciąg. Czyli oba poniższe zapisy są jednoznaczne. Sami oceńcie, który jest czytelniejszy.

cipher.encrypt(message, 2)
cipher.encrypt(cipher.encrypt(message))

Skoro znamy już zasadę szyfrowania przejdźmy do ciała naszej metody. Na początku do naszego pola spin , wstrzykujemy podwójnie zaszyfrowaną datę. Z wcześniejszego wprowadzenia wiemy, że będzie to ciąg 64 bitowy. Następnie przechodzimy do deszyfrowania tego ciągu, żeby osiągnąć ciąg 32 bitowy. Nasz nowy ciąg będzie nazwą nowego pola w formularzu. Oczywiście jest to pole ukryte o pobranej wartości początkowej.

Walidacja danych

Przejdźmy do walidacji. Każdy kto pracował z formularzami z Django zerknął się z walidacją. Mamy bowiem bardzo wygodny interfejs do własnego sprawdzania poszczególnych pól. Skorzystajmy z metody clean_spin . Metodę tę rozbiłem na trzy mniejsze, żeby kod był czytelniejszy a logiki konkretnych sekcji były odseparowane.

Pamiętamy, że w polu trzymamy podwójnie zaszyfrowaną datę, czyli ciąg 64 bitowy. Zapisujemy zatem tę wartość w polu double_encrypted. Przekazujemy tę wartość do pierwszej prywatnej metody __check_time. W niej deszyfrujemy naszą wartość do ciągu 32 bitowego. Dzięki temu uzyskujemy nazwę pola. Dokładnie tak samo jak robiliśmy to przy inicjalizacji. O tym polu dowiemy się jeszcze więcej przy omawianiu szablonu. Na ten moment powinna nam wystarczyć informacja, że te pole przechowuje informację o tym jak długo formularz był na stronie. Sprawdzamy czy formularz był widoczny przez minimum 5 sekund, lub maksymalnie 600 sekund. Jeśli walidacja się powiodła zwracamy nazwę pola, oraz odczytaną z niego wartość, czyli sprawdzamy właśnie czas. Kolejny krok już znamy. Jest to bowiem znany już nam z wcześniej stempel czasowy. Zatem do metody __check_backendtime przekażmy nasz stempel. Podobnie jak wcześniej sprawdźmy czy data mieści się w podanym zakresie. Na koniec nowość. Porównujemy obie otrzymane wartości, czyli czas jaki formularz był widoczny z różnicą czasu ze stempla. Te czasy mogą się nieznacznie różnić, to normalne. Wystarczy, że ktoś wysłał formularz ułamek sekundy przed inkrementacją, a sama walidacja trwała dłużej niż sekundę. To bardzo często się zdarza a już może być w granicach 2 sekund. Dlatego właśnie sprawdzam, czy ich różnica nie przekracza określonego progu . Ten powinien być dobrany indywidualnie dla każdego formularza.

Do tego wszystkiego brakuje jeszcze naszego szablonu z JavaScript.

{% extends 'base.html' %}

{% block content %}
    {% if form.is_valid %}
        Dziękujemy! Zgłoszenie zostało wysłane
    {% else %} 
        <form method="post" action=".">
            {% csrf_token %}
            {{ form }}
	    <button type="submit">Wyślij</button>
        </form>
    {% endif %}

    <script>
        function init(){
            var element = document.getElementById('id_' + '{{ form.field_name }}');
            element.setAttribute('value', 0);
       }

        function increment(){
            var element = document.getElementById('id_' + '{{ form.field_name }}');
            element.setAttribute('value', parseInt(element.getAttribute('value')) + 1);
        }
        var incrementInterval;

        document.addEventListener("DOMContentLoaded", function() {
            init();
            incrementInterval = setInterval(increment, 1000);
        })
</script>
{% endblock %}

Zdaję sobię sprawę, że skrypty w szablonach nie są eleganckie ale mają ogromną zaletę. Możemy przekazać tu wartość z kontekstu szablonu. Mógłbym zrobić funkcje, które dostaną odpowiednie wartości w parametrze ale na potrzeby tego rozwiązania myślę, że to jest wystarczające i do zaakceptowania. Jaka jest przewaga tego rozwiązania nad wcześniejszym zwiększaniem licznika? Tu nie ma żadnego punktu zaczepienia. Wcześniej atakujący mógł złapać elementy o konkretnej klasie. Tu natomiast nazwa tego pola za każdym razem będzie inna. To wszystko powoduje, że jedynym rozwiązaniem złamania tego na front jest dodanie sztucznego oczekiwania. Ale to dalej jest wygrana po naszej stronie. Naszym celem jest bowiem ograniczenie spamu. Jeśli ktoś musi odczekać dajmy na to 10 sekund to już będzie tych wiadomości mniej. Oczywiście można taki atak zrównoleglić i jednocześnie przesyłać kilkaset zapytań. Ale takich sytuacji nie przeskoczymy. Jak ktoś się uprze to znajdzie sposób ;).

Nawet jeśli znalazłby się ktoś uparty, kto zamiast po nazwie rozpoznawałby ten element np po pozycji w szablonie to ostatni etap walidacji pomaga nam się z nim rozprawić. Dlaczego? Zauważcie, że sprawdzamy różnicę daty ze stempla do aktualnej. Nikt nie wie jaka wartość znajduje się w naszym polu. Dlatego właśnie niewiadomo jaką wartość wstawić w inkrementowanym polu, żeby ta różnica nie była zbyt duża.

Podsumowanie

Nie ma idealnego zabezpieczenia. A przynajmniej Ja takiego nie znam. Ale każde zabezpieczenia zmniejsza szansę wystąpienia spamu. Im więcej tych zabezpieczeń tym ta szansa jest mniejsza. Nie można jednak popadać w skrajność i na formularzach, które mają po kilka linijek nakładać od razu kilkaset linijek dodatkowej walidacji. Dlatego starałem się zaproponować rozwiązanie możliwie elastyczne, stosunkowo proste i przede wszystkim niezależne od bazy.

Na początku mojej walki z botami łączyłem honeypot, inkrementację w javascript i stempel czasowy. Przygotowując się do prezentacji na PyStok, wpisu na blogu oraz konsultując to z bardziej doświadczonymi programistami zauważyłem, że można to połączyć bardzo sprytnie i wydaje mi się, że się udało. Do tego posłuchałem rady i dodałem zmienną nazwę pola, które jeszcze bardziej zabezpieczyło formularze.

Zaprezentowane przeze mnie rozwiązanie również ma wady. Największym ograniczeniem jest bowiem oporne odwoływanie się do formularza w szablonie. Żeby cały mechanizm zadziałał należy wywołać cały formularz. Sam najczęściej odwołuję się do tych pól ręcznie, bo pozwala to na dowolne ułożenie tych pól i lepsze wpasowanie tego w strukturę szablonu. Dlatego planuję to uporządkować i wypuścić jako aplikację OpenSource na GitHub. Zachęcam gorąco do kontrybuowania, bo wspólnie możemy zbudować coś fajnego :). Może uda się zrobić w formie jednego pola zamiast całego formularza? Kto wie. Tymczasem zachęcam do zapoznania się z innymi wpisami.

Kwi 17, 2019

Najnowsze wpisy

Zobacz wszystkie