angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Flask, WTForms en AJAX: CSRF bescherming, before_request en meertalige bescherming

U moet altijd controleren of de CSRF -beveiliging werkt. Met Flask is dit niet vanzelfsprekend.

29 februari 2020
post main image
https://unsplash.com/@christineashleydonaldson

Ik heb nooit echt gecontroleerd of CSRF bescherming werkte in mijn Flask applicatie, deze website. Is het standaard ingeschakeld? Uit de Flask_WTF uitbreidingsdocumentatie:

Elke view die FlaskForm gebruikt om het verzoek te verwerken, krijgt al CSRF bescherming.

En uit de tekst van Miguel Grinberg's post 'Cookie Security for Flask Applications':

Als u uw webformulieren met de extensie Flask-WTF behandelt, bent u standaard al beschermd tegen CSRF op uw formulieren.

Het moet worden ingeschakeld, laten we eens kijken of dit waar is. Op de pagina met het formulier dat ik heb toegevoegd:

<script>
$(document).ready(function(){
    $('#csrf_token').val('ABC');
});
</script>

Toen heb ik het formulier opgefrist. In de debugger heb ik geverifieerd dat het csrf_token veranderd is in 'ABC'. Door op de submit button te klikken zou een CSRF fout moeten geven, nou ik verwachtte een CSRF uitzondering. Voor mij geen uitzondering, wat betekent dat er geen CSRF bescherming is. Hoe is dit mogelijk? Had het te maken met het feit dat ik DispatcherMiddleWare gebruik of is er iets anders mis?

Ja, er was iets mis. Ik! En het werd veroorzaakt door TL;DR. Er was in feite een CSRF -fout, maar geen CSRF -uitzondering. Ik heb veel formulieren, meestal in de admin sectie, en ze hebben de hele tijd gewerkt met CSRF bescherming zonder enige CSRF fout. Wat is er aan de hand?

Mijn testapp:

def  create_app():
    fname = 'create_app'
    print(fname  +  '()')

    app =  Flask(__name__)

    app.config['SECRET_KEY'] = 'some-secret-key'

    class MyCSRFForm(FlaskForm):
        submit = SubmitField(_l('Send'))

    @app.route('/csrf_form', methods=['GET', 'POST'])
    def csrf_form():
        fname = 'csrf_form'
        print(fname  +  '()')

        form = MyCSRFForm()
        if form.validate_on_submit():
            print(fname  +  ': no validate_on_submit errors, processing form')

        print(fname  +  ': form.errors = '.format(form.errors))
        for field, errors in form.errors.items():
            print(fname  +  ': form.errors, field = {}, errors = {}'.format(field, errors))
                
        return  render_template('csrf_form.html', form=form)

en het csrf_form.html sjabloon:

<html>
<head></head>
<body>

<form method="post">
    {{ form.csrf_token }}
    <p>{{ form.submit() }}</p>
</form>

<script>
elem = document.getElementById('csrf_token');
elem.setAttribute('value', 'ABC'); 
</script>

</body>
</html>

Het resultaat:

csrf_form: form.errors, field = csrf_token, errors = ['The  CSRF  token is invalid.']

Meer informatie hierover staat in 'Inconsistentie met het verhogen van CSRFError #381', zie de links hieronder. Het blijkt dat u de Flask-WTF bescherming op twee manieren kunt gebruiken:

  • als CSRF -foutmelding tijdens de formuliervalidatie
  • als CSRF -uitzondering

Beide hebben voor- en nadelen. Ik heb besloten om voor de CSRF uitzondering te gaan omdat je dit niet per ongeluk kunt vergeten. Om CSRF uitzonderingen te genereren, heb ik de voorgestelde code in mijn __init__.py toegevoegd:

from flask_wtf.csrf import  CSRFProtect
csrf =  CSRFProtect()
...

def  create_app(project_config):
    ...
    # csrf protection
    csrf.init_app(app)

Als ik het formulier inlever, krijg ik een CSRF uitzondering:

Bad Request
The  CSRF  token is invalid.

Ik heb de jQuery gewijzigd en de veldnaam csrf_token verwijderd:

<script>
$(document).ready(function(){
    $('#csrf_token').attr({name: 'nothing'});
});
</script>

Vernieuwen en indienen, de CSRF uitzondering is:

Bad Request
The  CSRF  token is missing.

En als laatste test verwijderen we het script en stellen we het CSRF -token in create_app() in op 5 seconden:

    app.config['WTF_CSRF_TIME_LIMIT'] = 5

De CSRF uitzondering is:

Bad Request
The  CSRF  token has expired.

Samenvatting van deze tests:

  • De foutcode van http is 400 Slecht verzoek.
  • Het bleek dat CSRF bescherming standaard werkte en op twee manieren kan worden geïmplementeerd, de documentatie is hierover niet erg duidelijk
  • Ik wilde CSRF uitzonderingen en voegde de voorgestelde extra code toe.
  • Het was zeer eenvoudig om CSRF uitzonderingen voor de voorwaarden te genereren:
    - Het teken CSRF is ongeldig
    - Het teken CSRF ontbreekt
    - Het teken CSRF is verlopen.

Meerdere formulieren en de CSRF penning

Een blogpostpagina op deze site heeft twee soorten comment forms: de comment form en de comment reply form, zie ook een eerdere post.
Bij het bekijken van de pagina met de comment form en comment reply form merkte ik dat beide de CSRF tokenwaarde hadden na het laden van een pagina.

comment form: 
<input id="comment_form-csrf_token" name="comment_form-csrf_token" type="hidden" value="IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4">

comment reply form: 
<input id="comment_reply_form-csrf_token" name="comment_reply_form-csrf_token" type="hidden" value="IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4">

of:

comment_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4

comment_reply_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4

Dan stuur ik een te kort bericht met de comment reply form. Het formulier wordt naar de server gestuurd, op de server weergegeven en teruggestuurd naar de client.
Inspectie toonde aan dat de CSRF token van de comment reply form veranderde:

comment_reply_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6SUg.6axMGCN2KmQQ4WeAAxYgTg6UdAs

Hier kwamen de volgende vragen aan de orde:

  • Hoe is de CSRF -token geconstrueerd?
  • Wanneer verloopt het CSRF -token?
  • Hebben verschillende formulieren op een pagina een ander CSRF -token nodig?
  • Hoe kan de CSRF -token van de comment reply form veranderen?
  • Wat is het X-CSRFToken?

Hoe is de CSRF -token geconstrueerd?

Tijd om weer te gaan lezen. Uit de OWASP gids:

In het algemeen hoeven ontwikkelaars dit token maar één keer te genereren voor de huidige sessie. Na de eerste generatie van dit token wordt de waarde opgeslagen in de sessie en wordt deze gebruikt voor elke volgende aanvraag tot de sessie afloopt.

In Flask-WTF staat de ruwe penning in de sessie en de gesigneerde penning in g. Of nauwkeuriger, de ruwe penning is altijd in de sessie en de gesigneerde penning is in g na gebruik van een formulier. Voordat u een formulier instant:

session['csrf_token']: 387f76a30ad4c09663398b455c8d04272672d18b
g.csrf_token:  None

Na het instantiëren van een formulier:

session['csrf_token']: 387f76a30ad4c09663398b455c8d04272672d18b
g.csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6vnw.L7uNOptXpBxemh_TIEy3e9Ujllg

De getekende penning wordt in g gezet zodat bij latere verzoeken, bijvoorbeeld bij meerdere formulieren, dezelfde getekende penning kan worden gebruikt en niet opnieuw hoeft te worden gegenereerd. Als je wilt kun je experimenteren met het genereren en valideren van tokens met behulp van de functies generate_csrf() en validate_csrf():

    from flask_wtf.csrf import generate_csrf, validate_csrf

    secret_key =  current_app.config['SECRET_KEY']
    # default: WTF_CSRF_FIELD_NAME = 'csrf_token'
    token_key =  current_app.config['WTF_CSRF_FIELD_NAME']

    csrf_token = generate_csrf(secret_key=secret_key, token_key=token_key)
     current_app.logger.debug(fname  +  ': csrf_token = {}'.format(csrf_token))

    validated = False
    try:
        validate_csrf(csrf_token, secret_key=secret_key, time_limit=3600, token_key=token_key)
        validated = True
    except:
        pass
     current_app.logger.debug(fname  +  ': validated = {}'.format(validated))

De bovenstaande functies doen zoiets als een zwarte doos. Maar we kunnen zien dat de gegenereerde penning is ondertekend en een tijdstempel bevat.

Wanneer verloopt het CSRF -token?

We zagen al dat de CSRF token de verlooptijd kan worden geregeld met:

    app.config['WTF_CSRF_TIME_LIMIT'] = 5

De standaardwaarde is 3600, een uur. Deze keer is het ergens in de penning. Er zijn twee mogelijke manieren om dit te implementeren:

  • De starttijd staat in de token CSRF
  • De eindtijd is in de token CSRF

Dit is niet belangrijk om te weten, tenzij u de vervaltijd binnen uw applicatie wilt wijzigen. De functie generate_csrf() bevat geen tijdslimiet parameter terwijl de validate_csrf dat wel doet. Dit zou betekenen dat de starttijd in de token CSRF staat. Om dit te controleren zet ik een pagina met een formulier op het scherm, wijzig de standaard tijdslimiet in 10 seconden en verstuur het formulier. Zoals verwacht werd er een CSRF uitzondering gemaakt.

Er is nog een andere voorwaarde wanneer uw CSRF -token verloopt en dat is wanneer de sessie afloopt. De reden hiervoor is dat de CSRF raw token in de sessie wordt opgeslagen. Dit betekent dat als uw sessie korter leeft dan uw CSRF -token, u CSRF -fouten kunt krijgen.

Hebben verschillende formulieren op een pagina een ander CSRF -token nodig?

Antwoord: Dit is meer een algemene vraag. Het antwoord is nee. We kunnen hetzelfde CSRF -token gebruiken voor alle formulieren op een pagina.

Hoe kan de CSRF -token van de comment reply form veranderen?

Antwoord: De loper verandert niet, maar de gesigneerde loper verandert, omdat er ook een tijdstempel in de gesigneerde loper zit. Als u naar de gesigneerde lopers hierboven kijkt, ziet u dat het eerste deel identiek is.

De parameter X-CSRFToken koptekst

Mogelijk heeft u over het X-CSRFToken gelezen. Het X-CSRFToken is een instelling in de kop. Meestal hebben we dit niet nodig. Als u een CSRF token parameter in uw AJAX POST heeft dan wordt deze gebruikt. Zo niet, dan kan dit in de kop van de POST met behulp van jQuery worden ingesteld:

var csrf_token = ...

    ...
    $.ajax({
        beforeSend: function(xhr, settings){
            if(!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain){
                xhr.setRequestHeader("X-CSRFToken", csrf_token);
            }
        },

Het hangt van uw implementatie af of u formulieren met een CSRF token parameter verstuurt of dat u de X-CSRFToken header parameter instelt. In de documentatie staat dat u dit alleen kunt gebruiken in 'CSRF exception mode' (het toevoegen van de extra code). Ik heb dit niet gecontroleerd.

Behandeling van CSRF uitzonderingen en before_request

Ik behandel al HTTP-foutuitzonderingen zoals 401 (Onbevoegd), 403 (Verboden), 404 (Niet Gevonden), 405 (Methode Niet Toegestaan), 500 (Interne Serverfout), maar hoe gaan we om met de CSRF -uitzondering? De CSRF -fout verspreidt zich naar een 400 (slecht verzoek).

Ik heb mooie klokken- en fluitjesfoutpagina's gemaakt met de uitzonderingen. Ik deed dit door te vertrouwen op het feit dat before_request werd genoemd.
In before_request I verwerkt u de inkomende aanvraag en stelt u onder andere de geselecteerde taal in.

Helaas wordt before_request standaard NIET aangeroepen op een CSRF uitzondering. Maar er is een manier om dit te omzeilen. We kunnen het instellen:

    app.config['WTF_CSRF_CHECK_DEFAULT'] = False

Het resultaat zal zijn dat before_request wordt aangeroepen. Dan kunnen we in before_request, na enkele essentiële bewerkingen, bellen:

     csrf.protect()

Dit zal een CSFR-uitzondering opleveren, als die er is, en de foutafhandelaar dienovereenkomstig oproepen.

Meertalige CSRF uitzonderingsberichten

Ik gebruik Flask-Babel en validatieberichten worden vertaald in de geselecteerde taal. De CSRF uitzonderingsberichten zijn niet vertaald en bij controle van csrf.py bleek dat deze berichten in het Engels(?) hard-codering hebben. Voorlopig heb ik een woordenboek gemaakt om de vertaling te verzorgen:

    csrf_error_message2translated_error_messages = {
        ...
        'The  CSRF  token has expired.': _('The  CSRF  token has expired.'),
        ...
    }
    if error.description in error_description2error_messages:
        error_message = error_description2error_messages[error.description]
    else:
        error_message = error.description

Reageren op uitzonderingen, waaronder CSRF, op AJAX verzoeken

Met AJAX kunnen we onze mooie HTML foutpagina niet op een uitzondering laten zien. Op verzoek moeten we een foutcode of HTML terugsturen die de klant begrijpt. Het komt erop neer dat de uitzondering als een JSON gecodeerde foutmelding naar de klant wordt teruggestuurd. In Flask hebben we al onze error handlers.
Maar hoe weten we dat het verzoek afkomstig was van een AJAX oproep? Ik stuur het formulier met codering application/x-www-form-urlencoded:

    $.ajax({
        data: $('#'  +  form_id).serialize()   +  '&'  +  encodeURI( $(submit_button).attr('name') )  +  '='  +  encodeURI( $(submit_button).val() ),
        type: 'POST',
        url: url_comment_new,
        contentType: 'application/x-www-form-urlencoded; charset=UTF-8', 
        dataType: 'json'
    })

Een manier is om te kijken naar de ontvangen Accepteer header.

     current_app.logger.error(fname  +  ': request.accept_mimetypes = {}'.format(request.accept_mimetypes))
    # text/javascript,application/json,*/*;q=0.01

Uit het document 'Lijst van standaardwaarden aanvaarden':

Merk op dat alle browsers het */* MIME Type toevoegen om alle gevallen te dekken. Dit wordt meestal gebruikt voor verzoeken die worden geïnitieerd via de adresbalk van een browser, of via een HTML <a> element.

Dit betekent dat zowel 'request.accept_mimetypes.accept_json' als 'request.accept_mimetypes.accept_html' waar zijn. Niet erg nuttig. De accept_mimetypes staan vermeld in volgorde van voorkeur. We zouden de lijst kunnen scannen en besluiten om JSON terug te geven wanneer we 'application/json' voor 'text/html' tegenkomen. Maar ik weet niet precies welke browsers welke_mimetypes accepteren, wat betekent dat ik op dit moment niet 100% zeker ben dat de 'zonder AJAX' foutpagina's niet per ongeluk als JSON zullen worden weergegeven.

Een veel veiligere manier is om een parameter toe te voegen aan AJAX requests en in de error handler te controleren of deze parameter aanwezig is. Als het aanwezig is dan sturen we een JSON foutmelding terug, als dat niet het geval is dan geven we de standaard foutpagina weer. Ik vind dit niet leuk, maar het werkt wel, en er zal nog wel wat tijd overheen gaan.

Samenvatting

U moet altijd controleren of de CSRF -beveiliging werkt. Dat deed ik niet, en dat was het begin van veel lezen. Het bleek dat er twee manieren zijn om met CSRF bescherming om te gaan, CSRF vormfout en CSRF uitzondering. Ik kies de tweede. Voor mij was het omgaan met CSRF bescherming voor deze meertalige site niet standaard, de belangrijkste reden hiervoor is dat before_request niet standaard wordt aangeroepen op een CSRF uitzondering. In before_request controleer ik onder andere de taal en stel deze in zodat deze echt moet lopen. Gelukkig kunnen we de CSRF bescherming uitstellen en deze expliciet in before_request aanroepen na essentiële verwerking met csrf.protect(). We hebben al onze uitzonderingsfoutpagina's, maar met AJAX verzoeken moeten we JSON foutmeldingen retourneren. Het detecteren of het verzoek afkomstig is van een AJAX verzoek is complex, dus ik heb een extra parameter gebruikt in de AJAX verzoeken.

Links / credits

Cookie Security for Flask Applications
https://blog.miguelgrinberg.com/post/cookie-security-for-flask-applications

Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet
https://owasp.org/www-project-cheat-sheets/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#Prevention_Measures_That_Do_NOT_Work

Flask-WTF CSRF Protection
https://flask-wtf.readthedocs.io/en/latest/csrf.html

Generating CSRF tokens for multiple forms on a single page
https://stackoverflow.com/questions/14715250/generating-csrf-tokens-for-multiple-forms-on-a-single-page

How to use Flask-WTForms CSRF protection with AJAX?
https://stackoverflow.com/questions/31888316/how-to-use-flask-wtforms-csrf-protection-with-ajax

Inconsistency with raising CSRFError #381
https://github.com/lepture/flask-wtf/issues/381

List of default Accept values
https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values

Posting a WTForm via AJAX with Flask
https://medium.com/@doobeh/posting-a-wtform-via-ajax-with-flask-b977782edeee

Unacceptable Browser HTTP Accept Headers (Yes, You Safari and Internet Explorer)
https://www.newmediacampaigns.com/blog/browser-rest-http-accept-headers

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen (1)

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.

avatar

Thanks for placing this post.
I have a problem with my webapp that raises CSRF errors, which I asked in
https://stackoverflow.com/questions/65985400/getting-csrf-errors-from-put-fetch-request-that-does-not-involve-flask-forms
Can you please explain why I'm getting CSRF error in the first place, and how to fix the problem?
Thanks again.
Avner