Make a Django app insecure? It's not easy and that's a good thing!

Make a Django app insecure? It's not easy and that's a good thing!

The OWASP Top 10 describes the most critical and most commonly occurring security flaws in web applications. This list is published every three years and although some issues move up the list and others move down, it broadly stays the same.

By Patrick Craston

Head of Software Development

19 October 2015

Here is the latest OWASP Top 10:

  • A1 - Injection
  • A2 - Broken Authentication and Session Management
  • A3 - Cross-Site Scripting (XSS)
  • A4 - Insecure Direct Object References
  • A5 - Security Misconfiguration
  • A6 - Sensitive Data Exposure
  • A7 - Missing Function Level Access Control
  • A8 - Cross-Site Request Forgery (CSRF)
  • A9 - Using Known Vulnerable Components
  • A10 - Unvalidated Redirects and Forwards

(from https://www.owasp.org/index.php/Top_10_2013-Top_10)

Although the most recent list is from 2013, my colleagues in our Assurance department tell me it remains a good reflection of the issues that we find in our clients' web applications on a daily basis. So why is it that, across the board, web applications are vulnerable to the same security issues, all the way from small projects to big corporate environments? Because web application security is hard! 

A typical web application is made up of many layers and each layer provides a whole host of attack vectors. As seen in the OWASP Top 10, vulnerabilities can crop up anywhere from server configuration to database interaction and front-end functionality. And often it is exactly this complex interplay of the various components that leads to unforeseen security issues.

On top of that, programmers are constantly working with limited budgets and tight deadlines, hence securing an application can often be lower on the priority list than getting new features rolled out to users.

Building an insecure Django web app...

In this blog post, I will argue that by building web applications using a modern web framework like Django (Disclaimer: Other modern web frameworks are available!) this problem can be alleviated. I will show how Django comes with a number of built-in features that not only help developers code more securely but actually make it really difficult to add some of the most common and most severe security vulnerabilities to a web application.

So to demonstrate this, I will show how to add some of the most commonly found security vulnerabilities to a Django web application. This isn't just an intellectual exercise, as we were asked to do exactly this in a recent engagement for one of our clients. It turns out that by trying to build an insecure application you actually learn a lot about web application security.

Step 1: Leak some information

That one is easy: Leave DEBUG = True set in your project settings.py and deploy your project to production.

It seems like an obvious one and Django does its best to remind you both in the Django documentation and when you create a new project (see above code snippet), but there are still plenty of Django sites out there running in debug mode (7% of 3703 websites according to a survey performed by Erik Romijn).

Running your production application in debug mode means that should a server error occur, the user can see your source code and local variables (hence you're leaking potentially sensitive data, i.e. OWASP A6) and also what 3rd party libraries your app is using (which means you're potentially exposed to OWASP A9 if you haven't updated those libraries in a while).

Step 2: Add some Cross-site Scripting (XSS)

Adding XSS is another easy one: Disable Django's automatic escaping of variables in templates. 

This can be done by rendering a database variable that can be modified via the user interface into a template and either

<p>
  {{ my_var_with_nasty_code|safe }}
</p>

Or wrapping the template code that contains the variable in an autoescape block:

<p>
  {% autoescape off %}
    {{ my_var_with_nasty_code }}
  {% endautoescape %}
</p>

If an attacker inserts HTML or JavaScript into the database of the application, any user viewing a page that renders a (unescaped) compromised database field into its template would have the code executed in their browser.

The crucial bit here is that unless you explicitly tell Django not to, it escapes all variables in templates automatically thus adopting the principle of security by default. So even if a user could insert malicious HTML into your database, that code would be escaped and not executed in the browser when displayed back to the user. Happy days - by default we're protected from the Cross-site Scripting (OWASP A3).

Step 3: Add SQL injection

SQL injection, together with the other injection flaws included in OWASP A1, headed up the top 10 list both in 2010 and 2013 (and came second in 2007). When it comes to the damage an attacker can do with regards to stealing sensitive data or even deleting it, SQL injection is probably one of the worst ones out there.

As always, xkcd explains it best:

(reproduced from https://xkcd.com/327/)

SQL injection most commonly occurs in when processing user input from web forms. In Django, to create a form you typically have a template that renders the HTML form and any output, a Form class that captures the form properties and its input fields and a View that renders the form into the template and then processes the user input when the form is submitted (See the Django documentation for details).

The good news for the Django developer is that, if the standard form-processing approach is adopted, it's really hard to add SQL injection to a Django app. On the flip side, that's bad news for me so here's what I had to do to make our app vulnerable to SQL injection.

First, let's start with an HTML template:

<html>
  <form method="get" action="">
    <input type="date" name="start_date" />
    <input type="date" name="end_date" />
    <input type="submit" value="Search date range" />
  </form>
  <table>
    <thead>
      <tr>
        <th>Date</th>
        <th>Some other attribute</th>
      <tr>
    </thead>
    <tbody>
    {% for item in model_items %}
      <tr>
        <td>{{ item.date }}</td>
        <td>{{ item.other_attribute }}</td>
      </tr>
    {% endfor %}
    </tbody>
  </table>
</html>

Then we need the view:

def search_form(request):
    if 'start_date' in request.GET and 'end_date' in request.GET:
        query = "SELECT * from myapp_mymodel WHERE myapp_mymodel.date "\
                "BETWEEN '{} 00:00:00' AND '{} 23:59:59'".format(request.GET.get('start_date'),
                                                                 request.GET.get('end_date'))
        model_items = MyModel.objects.raw(query)
    else:
        model_items = MyModel.objects.all()
    return render(request, 'template.html', {'model_items': model_items})

Note how I cannot use a Form class and also cannot make use of Django's database framework. The Form class' validation framework comes with a clean method that would automatically check that the date input fields are actually valid dates (and thus scrub any nasty SQL that the attacker might have added as GET parameters). And if I use the Django database framework filter statement (e.g.  MyModel.objects.filter(date__range=(request.GET.get('start_date'), request.GET.get('end_date'))), Django would reject any GET parameters that are not valid dates, hence malicious SQL statements would have no effect.

Instead, I have to create our SQL query manually and then feed it into the raw() function. It looks nasty (it's an unwelcome reminder of the bad old days when we built web apps using basic PHP!) and a lot more effort than applying a filter to a Django queryset, so hopefully that would put anyone off doing this by mistake.

By manipulating the GET parameters, the view is now vulnerable to UNION based SQL injection. This would allow an attacker to enumerate the database and, depending on how the type of database and how it was configured, potentially run system commands.

Step 4: Add Cross-site Request Forgery (CSRF)

Like XSS, Django's default behaviour with regards to Cross-site Request Forgery protection is secure, as the CSRF protection middleware is automatically enabled when processing POST data.

However, making the view susceptible to CSRF is easy. All we have to do is add the @csrf_except decorator to the view (or in fact disable the CSRF middleware in the project settings) and it will happily process POST requests without a valid CSRF token.

Having said that, because Django provides a {% crsf_token %} template tag and adds automatic protection to views through its CSRF middleware, the developer does not need to worry about CSRF (as long as default guidelines are followed). This is a good thing! CSRF protection is a hard problem to deal with as exemplified by its continuing presence in the OWASP top 10 (A5 in 2010 and A8 in 2013).

Step 5: Escalate some privileges

Finally, I'll look at adding privilege escalation through Missing Function Level Access Controls (OWASP A7) into our Django application. Specifically, by means of Authorisation Bypass, a low privileged user will be able to add themselves as an "owner" of a hypothetical alarm system that is run by a Django web application. This imposes a serious risk and such an operation should only be available to application administrators/superusers. I will implement this vulnerability by adding controls that prevent a low privileged user from accessing a view using a GET request, but then "forget" to implement those same controls if that user sends data to the same view using a POST request.

The good news is that this is really hard to implement in Django. If form processing is implemented in the recommended way using a Form class, the web framework automatically ensures that any data values submitted via a POST match the form fields that were rendered in the form when the view was requested using GET. If they do not, the POST request gets rejected and Django throws a ValidationError.

Anyway, for anyone who's interested in how to get around this (not that you'd ever really want to this in the real world!), this is how you would do it. 

Our database model stores information about these hypothetical alarm systems that allow their owners to arm and disarm them remotely via the internet (the model has additional fields but they are omitted here for the sake of readability):

class AlarmSystem(models.Model):
    owners = models.ManyToManyField(User, null=True)
    pointless_text = models.TextField()

The Form class allows all users to edit the pointless_text attribute of the alarm system. However, only superusers can add or remove owners for alarm systems.

class AlarmOwnerForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        self.request = kwargs.pop('request', None)
        super(AlarmOwnerForm, self).__init__(*args, **kwargs)
        # The following code ensures that normal users do not see a ValidationError if they submit 
        # the form containing just the pointless_text field (i.e. if they are not tampering 
        # with the form!)
        if not self.request.user.is_superuser:
            self.fields['owners'].required = False
    class Meta:
        model = AlarmSystem
        fields = ('owners', 'pointless_text',)

The basic template that renders the form. All users can see and edit the pointless_text field, only superusers can see and edit the owners field.

<html>
  <form method="post" action="">
    {% if user.is_superuser %}
      {{ form.owners }}
    {% endif %}
    {{ form.pointless_text }}
    <button type="submit">Submit</button>
  </form>
</html>

And the view that renders and processes the form:

def my_form_view(request, serial_number):
    alarm_system = AlarmSystem.objects.get(serial_number=serial_number)
    if request.method == 'POST':
        # As we are not adjusting the Form class based on the user, we need to add the previous owners
        # to the request.POST to prevent ValidationErrors for normal users submitting the form
        request_post = request.POST.copy()
        if 'owners' not in request_post:
            request_post.setlist('owners', [unicode(owner.id) for owner in alarm_system.owners.all()])
        form = AlarmOwnerForm(request_post, instance=alarm_system, request=request)
    else:
        form = AlarmOwnerForm(instance=alarm_system, request=request)
        if form.is_valid():
            form.save()
    return render(request, 'template.html', {'form': form}

A lot of (pretty ugly) code, but basically what I am doing is directly modifying the form in the template depending on whether the user is a superuser or not. The secure way of implementing this would have been to dynamically modify the Form class based on the user's superuser status. Hence, Django requires us to add a whole lot of hacks to both the form and the view to make it process the vulnerable form.

Conclusion

Although some vulnerabilities are easy to add (and others are definitely hard), a modern web framework like Django protects the developer from the security risks described above. In all cases, the framework's default recommended approach is secure. 

For issues like CSRF or XSS, it is easy to make the application vulnerable, however, in most cases the developer would actively have to disable or amend the standard behaviour of the framework. A robust code review performed as part of the System Development Life Cycle (even if done by team members rather than security professionals) should detect any of these vulnerabilities.

Other vulnerabilities like SQL injection and privilege escalation - which for a long time have been amongst the most prevalent risks when it comes to web application security - are actually really hard to add when using a modern web framework such as Django as the developer has to actively work around the security functionality built into the framework. By using tools such as the ORM for database interaction and the forms framework for form processing, the developer is automatically protected from these risks (at least for many of the most common scenarios).

Contact and Follow-Up

Patrick is a Principal Software Engineer and leads the Software Development team in Context's London office. See the Contact page for how to get in touch.

About Patrick Craston

Head of Software Development