CSRF and Django
Investigate how csrf protection works, especially in django.
introduction
Cross site request forgery scenario
- user A has a browser tab open on site https://nice.com/
- user A has another browser tab open on site https://evil.com
- Evil knows nice's url and posts a request on nice on behalf of A
- Nice just honors the request because it recognizes A's cookies (A is still logged in)
To prevent this A should first request a secret token from nice.com and use that in all further calls. Evil does not have that token so the requests will fail.
Another way would be to check the referrer header, but users can disable or even fake that header.
django
Django's approach is to return a csrf_token on a safe request (GET) and included that token in unsafe requests(POST,PUT,DELETE).
GET should never change server state
Note that you need to make sure your GET request does not change state on the server because tat makes it unsafe after all.
To enable CSRF protection :
- Nothing... because 'django.middleware.csrf.CsrfViewMiddleware' is enabled by default in the MIDDLEWARE setting for django.
- In every template that has a form add csrf_token like
- Use RequestContext when rendering the views, if you use render() it's already covered.
how it works
This section in the django docs is very clarifying
In short, without the comments on how the token is secured.
The CSRF protection is based on the following things:
- A CSRF cookie that is based on a random secret value, which other sites will not have access to. This cookie is set by CsrfViewMiddleware. It is sent with every response that has called django.middleware.csrf.get_token() (the function used internally to retrieve the CSRF token), if it was not already set on the request.
- A hidden form field with the name ‘csrfmiddlewaretoken’ present in all outgoing POST forms. This part is done by the template tag.
- For all incoming requests that are not using HTTP GET, HEAD, OPTIONS or TRACE, a CSRF cookie must be present, and the ‘csrfmiddlewaretoken’ field must be present and correct. If it isn't, the user will get a 403 error. This check is done by CsrfViewMiddleware.
- CsrfViewMiddleware verifies the Origin header, if provided by the browser, against the current host and the CSRF_TRUSTED_ORIGINS setting. This provides protection against cross-subdomain attacks.
troubleshooting
But of course this went wrong for me when creating a bypass login for cypress. So let's dive some deeper. The above 3 steps do all the work for you but what is that work ?
First the bypass login, here is the url, form and template
# note this is different from /login (sso) this is /users/login
path("login/", views.UserLoginView.as_view(), name="userlogin"),
class LoginForm(forms.Form):
username = forms.CharField()
password = forms.CharField(widget=forms.PasswordInput)
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
[visit](div class="jumbotron")
[visit](h1)Please login[visit](/h1)
[visit](form action="." method="post")
[visit](label for="username")Username:[visit](/label)
[visit](input type="text" name="username" value="" placeholder="Enter Username")
[visit](label for="password")Password:[visit](/label)
[visit](input type="password" name="password")
[visit](input type="submit" name="" value="Login")
[visit](/form)
[visit](/div)
{% endblock content %}
If you view the headers of the normal login request, which works. In the developer console you can see the relevant requests/responses in 'network' and then use the 'doc' tab to show the main requests.
When you first visit http://localhost:8000/users/login The cookies have a csrf_token inside.
Cookie: _ga=GA1.1.572573231.1641126165; news=1610409600000; docs=angular~12/javascript/klopt; schema=9; count=13; version=1646222819; csrftoken=q6kAyJ8V52KtNtAns3OCryZMGzGAPwPD4cOMrUEeAFsDI5hvGPh3wSxL7ne2beDQ
It is the same every time, and seems to be the current/last token stored in cookieform. Without the csrf_token line inside the form, it will say forbidden even with that token in the cookie.
Now add the line inside the form.
If you refresh, nothing changes because it will post the same request. If you go back to the login page and login again you get a different response header.
Set-Cookie: messages="9c178c1f07f9a4333a0d0fc9dc9c67e2f1c65378$[[\"__json_message\"\0540\05425\054\"User has access...\"]]"; HttpOnly; Path=/; SameSite=Lax
Set-Cookie: csrftoken=sIBcSRuvsoEcvJCLBvmblKGLGpHN7oUWaSjVVB6VS6C5T354kFlyzpLNzWsPqDuG; expires=Tue, 04 Apr 2023 16:14:04 GMT; Max-Age=31449600; Path=/; SameSite=Lax
Set-Cookie: sessionid=o6utjhhpt2wu10id61i5yapxfxaa2s5d; expires=Tue, 19 Apr 2022 16:14:04 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
The csrftoken is now a different one in the response, note it is still the old one in the request headers. And also it get's a session id which means were in business.
You the get redirected to /(home) so you get a new request where both the session id and token are set in the cookies.
cypress run
Now to see what goes wrong in the cypress run. Cypress has no direct support for django, so an example i found tries to get the csrf token from the request headers like this
And from the headers.
But it is clear both these will fail since django returns the csrf_token as set-cookie headers. So to get the token we use a custom function.
python script
Go back to basics, and see how things work. We start with a simple python3 script that queries the server.
| simple request | |
|---|---|
When you look at the returned form, you will notice it has changed.
So we need to pass csrfmiddlewaretoken as a form parameter from the python script. So first lets try to retrieve it from the request, and use it in the subsequent post :
This indeed works and get's redirected to the main page.
To bad this is python, and cypress is javascript.