Django, uWSGI et gunicorn sont sur un bateau (CSRF tombe à l'eau) (FR)
lun. 15 avril 2019 Dan Lousqui403 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).
- Pourquoi faire cette sécurité uniquement en
HTTPS
? - Pourquoi ne pas avoir de
Referer
est considéré comme non sécurisé ? - 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 formulairePOST
qui visehttp://exemple.com/declance-bombe/
et l'envoie automatiquement parJavaScript
.L'attaquant devra fournir un cookie et un jeton
CSRF
, mais ce n'est pas un problème dans le cadre d'unMITM
car le jeton est indépendant de la session que nous utilisons. Donc leMITM
peut faire échouer la protectionCSRF
. Cela est vrai pour n'importe quelle connexionHTTP
, mais quiconque utilisantHTTPS
espère mieux (ndt.: sic) ! Pour cette raison, pourhttps://exemple.com/
, nous avons besoin de protections additionnelles qui traitenthttp://exemple.com/
comme non fiable. AvecHTTPS
, Barth et al. (ndt: ?) ont observé que l'en-têteReferer
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 duReferer
.
(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 surhttp://exemple.com/
; - On est en
MITM
, donc on peut modifier cette page (mais pasHTTPS
); - 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 enHTTP
; - 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 :-)