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

Flask, WTForms et AJAX : protection CSRF , before_request et multilingue

Vous devez toujours vérifier si la protection CSRF fonctionne. Avec Flask , ce n'est pas évident.

29 février 2020
Dans Flask, Security
post main image
https://unsplash.com/@christineashleydonaldson

Je n'ai jamais vraiment vérifié si la protection CSRF fonctionnait dans mon application Flask , ce site web. Est-il activé par défaut ? Extrait de la documentation de l'extension Flask_WTF :

Toute vue utilisant FlaskForm pour traiter la demande obtient déjà la protection CSRF .

Et d'après le texte de Miguel Grinberg's post 'Cookie Security for Flask Applications' :

Si vous manipulez vos formulaires web avec l'extension Flask-WTF , vous êtes déjà protégé par défaut contre l'extension CSRF sur vos formulaires.

Elle devrait être activée, vérifions si c'est vrai. Sur la page avec le formulaire que j'ai ajouté :

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

Puis j'ai actualisé le formulaire. Dans le débogueur, j'ai vérifié que le code csrf_token était bien "ABC". En cliquant sur le bouton "submit", vous devriez obtenir une erreur CSRF , mais je m'attendais à une exception CSRF . Pour moi, pas d'exception, ce qui signifie pas de protection CSRF . Comment cela est-il possible ? Est-ce que cela a un rapport avec le fait que j'utilise le DispatcherMiddleWare ou est-ce que quelque chose d'autre ne va pas ?

Oui, quelque chose n'allait pas. Moi ! Et elle a été causée par TL;DR. Il y avait en fait une erreur CSRF mais pas d'exception CSRF . J'ai de nombreux formulaires, la plupart dans la section administration, et ils ont travaillé avec la protection CSRF tout le temps sans aucune erreur CSRF . Que se passe-t-il ?

Mon 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)

et le modèle csrf_form.html :

<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>

Le résultat :

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

Vous trouverez plus d'informations à ce sujet dans la section "Incompatibilité avec l'augmentation de l'erreur CSRFError #381", voir les liens ci-dessous. Il semble que vous puissiez utiliser la protection Flask-WTF de deux façons :

  • sous la forme d'un message d'erreur CSRF lors de la validation du formulaire
  • comme une exception CSRF

Les deux présentent des avantages et des inconvénients. J'ai décidé de choisir l'exception CSRF car il est impossible de l'oublier par hasard. Pour générer les exceptions CSRF , j'ai ajouté le code suggéré dans mon __init__.py :

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

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

Maintenant, lorsque je soumets le formulaire, j'obtiens une exception CSRF :

Bad Request
The  CSRF  token is invalid.

J'ai modifié le jQuery et supprimé le nom du champ csrf_token :

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

Rafraîchir et soumettre, l'exception CSRF est :

Bad Request
The  CSRF  token is missing.

Et pour finir, nous supprimons le script et fixons le temps d'expiration du jeton CSRF à 5 secondes dans create_app() :

    app.config['WTF_CSRF_TIME_LIMIT'] = 5

L'exception CSRF est :

Bad Request
The  CSRF  token has expired.

Résumé de ces tests :

  • Le code d'erreur http est 400 Bad Request
  • Il semble que la protection CSRF fonctionne par défaut et peut être mise en œuvre de deux manières, la documentation n'est pas très claire à ce sujet
  • Je voulais des exceptions CSRF et j'ai ajouté le code supplémentaire suggéré
  • Il a été très facile de générer des exceptions CSRF pour les conditions :
    - Le jeton CSRF n'est pas valide
    - Le jeton CSRF est manquant
    - Le jeton CSRF a expiré

Formulaires multiples et le jeton CSRF

Une page de blog sur ce site présente deux types de comment form : le comment form et le comment reply form, voir aussi un article précédent.
En regardant la page avec les jetons comment form et comment reply form , j'ai remarqué que les deux avaient la valeur du jeton CSRF après un chargement de page.

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">

ou :

comment_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4

comment_reply_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4

Ensuite, je soumets un message trop court en utilisant le comment reply form. Le formulaire est envoyé au serveur, rendu sur le serveur et renvoyé au client.
L'inspection a montré que le jeton CSRF du comment reply form a changé :

comment_reply_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6SUg.6axMGCN2KmQQ4WeAAxYgTg6UdAs

Les questions suivantes ont été soulevées :

  • Comment le jeton CSRF est-il construit ?
  • Quand le jeton CSRF expire-t-il ?
  • Les différents formulaires d'une page nécessitent-ils des jetons CSRF différents ?
  • Comment le jeton CSRF peut-il changer ?
  • Qu'est-ce que le jeton X-CSRFToken ?

Comment le jeton CSRF est-il construit ?

Il est temps de recommencer à lire. Extrait du guide OWASP :

En général, les développeurs n'ont besoin de générer ce jeton qu'une seule fois pour la session en cours. Après la génération initiale de ce jeton, la valeur est stockée dans la session et est utilisée pour chaque demande ultérieure jusqu'à l'expiration de la session.

Dans Flask-WTF , le jeton brut est dans la session et le jeton signé est en g. Ou plus exactement, le jeton brut est toujours dans la session et le jeton signé est en g après l'utilisation d'un formulaire. Avant d'instancier un formulaire :

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

Après avoir instancié un formulaire :

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

Le jeton signé est mis en g afin que lors de demandes ultérieures, par exemple en cas de formulaires multiples, le même jeton signé puisse être utilisé et ne doive pas être généré à nouveau. Si vous le souhaitez, vous pouvez expérimenter la génération et la validation de jetons en utilisant les fonctions generate_csrf() et 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))

Les fonctions ci-dessus font quelque chose comme une boîte noire. Mais on peut voir que le jeton généré est signé et comporte un horodatage.

Quand le jeton CSRF expire-t-il ?

Nous avons déjà vu que le temps d'expiration du jeton CSRF peut être contrôlé avec :

    app.config['WTF_CSRF_TIME_LIMIT'] = 5

La valeur par défaut est de 3600, une heure. Cette fois-ci, c'est quelque part dans le jeton. Il y a deux façons possibles de mettre en œuvre cette politique :

  • L'heure de début se trouve dans le jeton CSRF
  • L'heure de fin se trouve dans le jeton CSRF

Il n'est pas important de le savoir, sauf si vous souhaitez modifier le délai d'expiration à l'intérieur de votre demande. La fonction generate_csrf() n'inclut pas de paramètre time_limit alors que la fonction validate_csrf en inclut un. Cela signifierait que l'heure de début se trouve dans le jeton CSRF . Pour vérifier cela, j'ai mis une page avec un formulaire à l'écran, puis j'ai changé le time_limit par défaut à 10 secondes et j'ai ensuite soumis le formulaire. Comme prévu, une exception CSRF a été soulevée.

Il existe une autre condition à l'expiration de votre jeton CSRF , à savoir l'expiration de la session. La raison en est que le jeton brut CSRF est stocké dans la session. Cela signifie que si votre session dure moins longtemps que le temps d'expiration de votre jeton CSRF , vous pouvez obtenir des erreurs CSRF .

Les différents formulaires d'une page nécessitent-ils des jetons CSRF différents ?

Répondez : Il s'agit d'une question plus générale. La réponse est non. Nous pouvons utiliser le même jeton CSRF pour tous les formulaires d'une page.

Comment le jeton CSRF peut-il changer ?

Répondez : Le jeton ne change pas mais le jeton signé change, car il y a également un horodatage dans le jeton signé. Si vous regardez les jetons signés ci-dessus, vous remarquez que la première partie est identique.

Le paramètre d'en-tête X-CSRFToken

Vous avez peut-être lu à propos du jeton X-CSRFToken. Le jeton X-CSRFToken est un paramètre de l'en-tête. En général, nous n'avons pas besoin de cela. Si vous avez un paramètre de jeton CSRF dans votre AJAX POST , celui-ci sera utilisé. Si ce n'est pas le cas, vous pouvez le définir dans l'en-tête de POST en utilisant jQuery :

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);
            }
        },

Cela dépend de votre mise en œuvre si vous envoyez des formulaires avec un paramètre de jeton CSRF ou si vous définissez le paramètre d'en-tête X-CSRFToken. La documentation indique que vous ne pouvez l'utiliser que dans le mode d'exception CSRF (en ajoutant le code supplémentaire). Je n'ai pas vérifié cela.

Traitement des exceptions CSRF et before_request

Je traite déjà les exceptions d'erreur HTTP comme 401 (non autorisé), 403 (interdit), 404 (introuvable), 405 (méthode non autorisée), 500 (erreur de serveur interne) mais comment traiter l'exception CSRF ? L'erreur CSRF se propage à un 400 (Bad Request).

J'ai créé de jolies pages d'erreurs de type "cloches et sifflets" qui montrent les exceptions. Je l'ai fait en m'appuyant sur le fait que le before_request était appelé.
Dans before_request , je traite la demande entrante et, entre autres, je règle la langue sélectionnée en conséquence.

Malheureusement, par défaut, before_request n'est PAS appelé sur une exception CSRF . Mais il y a un moyen de contourner ce problème. Nous pouvons nous fixer :

    app.config['WTF_CSRF_CHECK_DEFAULT'] = False

Le résultat sera que before_request est appelé. Ensuite, dans before_request, après un traitement essentiel, nous pouvons appeler :

     csrf.protect()

Cela permettra de lever une exception à la RFTS, s'il y en a une, et d'appeler le gestionnaire d'erreurs en conséquence.

Messages d'exception multilingues CSRF

J'utilise Flask-Babel et les messages de validation sont traduits dans la langue sélectionnée. Les messages d'exception CSRF n'ont pas été traduits et en vérifiant le site csrf.py, il est apparu que ces messages sont codés en dur en anglais ( ?). Pour l'instant, j'ai créé un dictionnaire pour gérer la traduction :

    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

Répondre aux exceptions, y compris aux demandes CSRF, sur AJAX

Avec AJAX , nous ne pouvons pas afficher notre belle page d'erreur HTML sur une exception. Sur une demande, nous devons renvoyer un code d'erreur ou HTML que le client comprend. Cela revient à renvoyer l'exception sous la forme d'un message d'erreur codé JSON au client. Dans Flask , nous avons déjà nos gestionnaires d'erreurs.
Mais comment savoir si la demande provient d'un appel AJAX ? J'envoie le formulaire avec le codage 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'
    })

Une façon de le faire est de regarder l'en-tête Accept reçu.

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

Extrait du document "Liste des valeurs par défaut acceptées" :

Notez que tous les navigateurs ajoutent le type */* MIME pour couvrir tous les cas. Il est généralement utilisé pour les demandes initiées via la barre d'adresse d'un navigateur, ou via un élément HTML <a>.

Cela signifie que "request.accept_mimetypes.accept_json" et "request.accept_mimetypes.accept_html" sont tous deux vrais. Pas très utile. Les accept_mimetypes sont énumérés par ordre de préférence. Nous pourrions scanner la liste et décider de renvoyer JSON lorsque nous rencontrons "application/json" avant "text/html". Mais je ne sais pas exactement quels sont les navigateurs qui envoient des mimetypes acceptés, ce qui signifie que je ne suis pas sûr à 100 % pour l'instant que les pages d'erreur "sans AJAX" ne seront pas accidentellement affichées sous la forme JSON.

Un moyen beaucoup plus sûr est d'ajouter un paramètre aux requêtes AJAX et de vérifier dans le gestionnaire d'erreurs si ce paramètre est présent. S'il est présent, nous renvoyons un message d'erreur JSON , sinon nous affichons la page d'erreur standard. Je n'aime pas cela, mais cela fonctionne et un certain temps permettra d'approfondir la question.

Résumé

Vous devez toujours vérifier si la protection CSRF fonctionne. Je ne l'ai pas fait, et ce fut le début de beaucoup de lecture. Il est apparu qu'il y a deux façons de traiter la protection CSRF , l'erreur de forme CSRF et l'exception CSRF . Je choisis la deuxième. Pour moi, le traitement de la protection CSRF pour ce site multilingue n'était pas standard, la raison principale étant que before_request n'est pas appelé par défaut sur une exception CSRF . Dans before_request , je vérifie et règle la langue entre autres choses, donc cela doit vraiment fonctionner. Heureusement, nous pouvons retarder la protection CSRF et l'appeler explicitement dans before_request après un traitement essentiel à l'aide de csrf.protect(). Nous avons déjà nos pages d'erreur d'exception mais avec les demandes AJAX , nous devons renvoyer les messages d'erreur JSON . Détecter si la demande provient d'une demande AJAX est complexe, j'ai donc utilisé un paramètre supplémentaire dans les demandes AJAX .

Liens / crédits

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

En savoir plus...

CSRF protection Flask

Laissez un commentaire

Commentez anonymement ou connectez-vous pour commenter.

Commentaires (1)

Laissez une réponse

Répondez de manière anonyme ou connectez-vous pour répondre.

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