• BLOG

  • Django, uWSGI et gunicorn sont sur un bateau (CSRF tombe à l'eau) (FR)

    Mon 15 April 2019 Dan Lousqui

    Share on: Twitter - Facebook - Google+

    ban

    403 FORBIDDEN: {"detail":"CSRF Failed: Referer checking failed - no Referer."}

    Ou comment j'ai découvert que Django implémente CSRF de façon... exotique, et que ça à tout cassé

    Un simple changement de serveur applicatif

    Pour ceux qui connaissent pas, Django est un framework Python très complet permettant de faire des applications web, incluant un moteur de template, un ORM, du routing, et plein d'autres trucs plus chouettes les uns que les autres.

    Cependant, comme la plupart des frameworks web Python, Django ne possède pas de serveur (au sens service) HTTP prêt pour la production.

    L'interface que Django fournit est un service de type WSGI, et les serveurs sous-jacents les plus utilisés sont uWSGI et gunicorn.

    Jusqu'à présent nous utilisions gunicorn, mais celui-ci présentait de nombreuses limites (performance, disponibilité, ...), il nous a été suggéré de migrer vers uWSGI.

    Le changement de serveur applicatif est très simple, il suffit juste de changer la commande d'exécution de serveur de gunicorn, vers uwsgi.

    Sauf que... le serveur se lance bien, l'application tourne bien... mais certaines requêtes authentifiées renvoient maintenant une erreur 403 FORBIDDEN avec comme réponse {"detail":"CSRF Failed: Referer checking failed - no Referer."}

    CSRF Failed ???

    Je ne vais pas revenir sur ce qu'est le CSRF, et comment s'en protéger (Je ne peux que vous conseiller également ce super article).

    Par contre, l'application en question est bien protégée contre le CSRF, et les requêtes qui donnent des erreurs envoient toutes bien un jeton CSRF valide, la migration de gunicorn vers uWSGI ne devrait absolument pas changer cela.

    Du coup, regardons dans le code de Django, ce qui pourrait causer un tel problème :

    [...]
    REASON_NO_REFERER = "Referer checking failed - no Referer."
    [...]
                if request.is_secure():
                    # Suppose user visits http://example.com/
                    # An active network attacker (man-in-the-middle, MITM) sends a
                    # POST form that targets https://example.com/detonate-bomb/ and
                    # submits it via JavaScript.
                    #
                    # The attacker will need to provide a CSRF cookie and token, but
                    # that's no problem for a MITM and the session-independent
                    # secret we're using. So the MITM can circumvent the CSRF
                    # protection. This is true for any HTTP connection, but anyone
                    # using HTTPS expects better! For this reason, for
                    # https://example.com/ we need additional protection that treats
                    # http://example.com/ as completely untrusted. Under HTTPS,
                    # Barth et al. found that the Referer header is missing for
                    # same-domain requests in only about 0.2% of cases or less, so
                    # we can use strict Referer checking.
                    referer = request.META.get('HTTP_REFERER')
                    if referer is None:
                        return self._reject(request, REASON_NO_REFERER)
    [...]
    

    (Source: django/middleware/csrf.py#L243)

    Donc en effet, quand la requête est sécurisée par HTTPS (1), s'il n'y a pas de Referer dans la requête (2), on déclenche une exception CSRF (3).

    1. Pourquoi faire cette sécurité uniquement en HTTPS ?
    2. Pourquoi ne pas avoir de Referer est considéré comme non sécurisé ?
    3. Quel rapport avec CSRF ?

    Pourquoi Django fait-il cela ?

    Peut-être que les réponses résident dans le commentaire ? Traduction pour les non-anglophones:

    Imaginons qu'un utilisateur visite http://exemple.com/. Un attaquant ayant la main sur le réseau (man-in-the-middle, MITM) envoie un formulaire POST qui vise http://exemple.com/declance-bombe/ et l'envoie automatiquement par JavaScript.

    L'attaquant devra fournir un cookie et un jeton CSRF, mais ce n'est pas un problème dans le cadre d'un MITM car le jeton est indépendant de la session que nous utilisons. Donc le MITM peut faire échouer la protection CSRF. Cela est vrai pour n'importe quelle connexion HTTP, mais quiconque utilisant HTTPS espère mieux (ndt.: sic) ! Pour cette raison, pour https://exemple.com/, nous avons besoin de protections additionnelles qui traitent http://exemple.com/ comme non fiable. Avec HTTPS, Barth et al. (ndt: ?) ont observé que l'en-tête Referer est manquante sur des requêtes appartenant au même domaine pour seulement 0.2% des cas, donc nous pouvons effectuer une vérification stricte du Referer.

    (ndt. 1: MITM)

    (ndt. 2: Je ne sais pas si le texte original est de mauvaise qualité, ou si c'est juste ma traduction ...)

    Reprenons le scénario d'attaque présenté :

    • Une personne malveillante peut écouter et modifier le traffic sur le réseau;
    • La victime, préalablement authentifiée sur https://exemple.com/, va sur http://exemple.com/;
    • On est en MITM, donc on peut modifier cette page (mais pas HTTPS);
    • L'attaquant génère un formulaire, qui sera soumis automatiquement, vers https://exemple.com/declenche-bombe/
    • Sur Django:
    • Le jeton CSRF n'est pas géré par les sessions, mais est dans un cookie;
    • Ce cookie n'est pas secure, donc récupérable depuis http://exemple.com/;
    • Le jeton CSRF peut donc être intégré au formulaire malveillant.
    • La victime affiche ce formulaire malveillant, et fourni un jeton CSRF valide;
    • L'attaque fonctionne.

    Dans ce scénario d'attaque, la requête "malveillante" aurait pour Referer: http://exemple.com/. La solution de Django est (entre autre) de refuser toutes les requêtes ayant un Referer en HTTP. Pour cela, il faut, au moins un Referer.

    Est-ce vraiment nécessaire ?

    Tl;dr : Non.

    Cela fait quelques années que l'on force les applications à être 100% HTTPS. Cela permet d'activer le flag HSTS dans les requêtes.

    HSTS est un paramètre HTTP permettant de forcer l'utilisation d'HTTPS. Ainsi, si l'utilisateur navigue sur l'application, le flag HSTS s'enregistrera dans le navigateur de l'utilisateur. Par la suite, si une requête devait être en HTTP simple, elle sera automatiquement transformée en HTTPS.

    Dans le scénario présenté, il faut que la victime soit préalablement authentifiée, elle aura donc le flag HSTS sur le cache de son navigateur. En allant sur http://exemple.com/, elle sera redirigée vers https://exemple.com/ sans faire aucune requête HTTP, donc inexploitable en MITM. Le site est donc déjà protégé par ce mécanisme.

    Autre chose... pourquoi le cookie CSRF de Django n'est-il pas secure ? (rendant impossible sa lecture en HTTP) Et bien, c'est le comportement par défaut. Il peut cependant l'être en ajoutant le paramètre CSRF_COOKIE_SECURE à True (ce que je recommande vivement !).

    Une autre possibilité est également de mettre le jeton CSRF en session via CSRF_USE_SESSIONS mais cela peut être problématique dans le cas de requêtes Ajax/XHR, légitimes, protégées contre les CSRF.

    Résolution du problème

    Nous avons donc un mécanisme sécurité plus ou moins inutile (désolé), qui empêche notre application de fonctionner.

    Nous avons deux possibilités:

    • Faire croire à Django que l'application est en HTTP ;
    • Ajouter un faux Referer manuellement, si celui-ci est vide.

    La première solution est moyennement satisfaisante, car si Django pense être en HTTP, les URLs qu'il générera seront en http://, donc fausses.

    La deuxième solution est un peu "patchwork", mais fait largement l'affaire.

    Pour cela, rien de plus simple, et sans toucher à l'application, dans le fichier de configuration nginx:

    [...]
    http {
    
    [+] map $http_referer $ref {
    [+]     default   $http_referer;
    [+]     ""        "https://$http_host/";
    [+] }
    
    [...]
        server {
    [...]
            location / {
                 uwsgi_pass django-app:8000;
                 include /etc/nginx/uwsgi_params;
    [+]          uwsgi_param HTTP_REFERER $ref;
            }
    [...]
       }
    }
    

    Cela permet, en cas de referer vide, d'ajouter comme Referer le Host courant (uniquement dans l'application Django)

    Pourquoi cela marchait avec gunicorn, mais est cassé avec uWSGI ?

    Maintenant que tout est corrigé, regardons comment un simple changement de serveur application a pu générer cette différence.

    Dans notre cas, le fait que le champ Referer soit vide n'est pas nouveau, c'était déjà le cas avant la migration. La différence est donc qu'avec gunicorn, Django pensait être en HTTP, et avec uWSGI, Django pense être en HTTPS.

    Qui a raison ? Les deux !

    Dans les deux cas, gunicorn et uWSGI écoutent en HTTP, les requêtes sont donc censées être en HTTP. Cependant, ces serveurs sont également derrières un reverse proxy HTTPS, donc finalement, la requête initiale est bien HTTPS, l'information est juste perdue quelque part.

    Dans notre configuration, nous n'avions pas mis en place le header X-Forwarded-Proto, c'est pour cela que gunicorn n'avait pas l'information.

    uWSGI, en cas de doute, met le HTTPS à True.

    J'espère que si d'autres personnes tombent sur le même problème que moi trouveront cette page, et leur permettra de passer moins de temps qu'il a fallu pour debugger tout ça :-)

  • Comments