Skip to content

CSRF and Django

Investigate how csrf protection works, especially in django.

introduction

visit

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 :

  1. Nothing... because 'django.middleware.csrf.CsrfViewMiddleware' is enabled by default in the MIDDLEWARE setting for django.
  2. In every template that has a form add csrf_token like
    [visit](form method="post"){% csrf_token %}
    
  3. 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

visit

In short, without the comments on how the token is secured.

The CSRF protection is based on the following things:

  1. 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.
  2. A hidden form field with the name ‘csrfmiddlewaretoken’ present in all outgoing POST forms. This part is done by the template tag.
  3. 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.
  4. 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

users/urls.py
# note this is different from /login (sso) this is /users/login
path("login/", views.UserLoginView.as_view(), name="userlogin"),
users/forms.py
class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField(widget=forms.PasswordInput)
The template
{% 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.

[visit](form method="post")
{% csrf_token %}
...

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

Try to get from html
 it('strategy #1: parse token from HTML', function () {
    // if we cannot change our server code to make it easier
    // to parse out the CSRF token, we can simply use cy.request
    // to fetch the login page, and then parse the HTML contents
    // to find the CSRF token embedded in the page
    cy.request('/users/login/')
    .its('body')
    .then((body) => {
      // we can use Cypress.$ to parse the string body
      // thus enabling us to query into it easily
      const $html = Cypress.$(body)
      const csrf = $html.find('input[name=_csrf]').val()

      cy.loginByCSRF(csrf)
      .then((resp) => {
        expect(resp.status).to.eq(200)
        expect(resp.body).to.include('[visit](h2)dashboard.html[visit](/h2)')
      })
    })

  })

And from the headers.

Try to get from response headers
     it('strategy #2: parse token from response headers', function () {
    // if we embed our csrf-token in response headers
    // it makes it much easier for us to pluck it out
    // without having to dig into the resulting HTML
    cy.request('/users/login/')
    .its('headers')
    .then((headers) => {
      const csrf = headers['x-csrf-token']

      cy.loginByCSRF(csrf)
      .then((resp) => {
        expect(resp.status).to.eq(200)
        expect(resp.body).to.include('[visit](h2)dashboard.html[visit](/h2)')
      })
    })

  })

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
1
2
3
4
5
6
7
import sys
import requests

URL = 'http://localhost:8000/users/login/'

client = requests.session()
response = client.get(URL)  # sets cookie

When you look at the returned form, you will notice it has changed.

The template
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
[visit](div class="jumbotron")

    [visit](h1)Please login[visit](/h1)
    [visit](form action="." method="post")
        {% csrf_token %}
        [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 %}
The resulting html
[visit](div class="jumbotron")

    [visit](h1)Please login[visit](/h1)
    [visit](form action="." method="post")
        [visit](input type="hidden" name="csrfmiddlewaretoken" value="RWHzaSCna0mA4RS4cqCPdcxQgPUIKrlyprE3vSBFmf9PkKV8GOGULEvBSsVWgwwv")
        [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)

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 :

simple request
import sys
import requests

URL = 'http://localhost:8000/users/login/'

client = requests.session()

# Retrieve the CSRF token first
client.get(URL)  # sets cookie
if 'csrftoken' in client.cookies:
    # Django 1.6 and up
    csrftoken = client.cookies['csrftoken']
else:
    # older versions
    csrftoken = client.cookies['csrf']

username="freek"
password= "freekspassword"

login_data = dict(username=username, password=password, csrfmiddlewaretoken=csrftoken, next='/')
r = client.post(URL, data=login_data, headers=dict(Referer=URL))

# btw : to print it readable, make it a string and replace the newlines
formatted_output = r.content.decode('utf-8').replace('\\n', '\n').replace('\\t', '\t')
print(formatted_output)

This indeed works and get's redirected to the main page.

To bad this is python, and cypress is javascript.