July 11, 2018

Securing Flask web applications

In this post I’d like to investigate security mechanisms available in Flask. I’ll go through different types of possible vulnerabilities and the way they can be mitigated.

XSS

Cross-Site Scripting (XSS) attacks are a type of injection, in which malicious scripts are injected into otherwise benign and trusted websites. source

Exploit

Consider a form asking for a user input.

<form method="post" action="/">
  <input type="text" name="tweet"><br>
  <input type="submit">
</form>

And a template to show tweets by other users where user input from above form passed unprocessed:

<title>Hello from flask twitter</title>
{% for tweet in tweets %}
  <h1 class={{tweet}}>{{ tweet }}!</h1>
  <a href="{{tweet}}">Like</a>
{% endfor %}

With the Flask app looking like this:

from flask import Flask, request, render_template, make_response
app = Flask(__name__)

tweets = []


@app.route('/', methods=['GET', 'POST'])
def tweet_feed():
    if request.method == 'POST':
        tweet = request.form['tweet']
        tweets.append(tweet)
    return render_template('tweet_feed.html', tweets=tweets)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5005, debug=True)

The tweet posted by user will be used as h1 class attribute and inside the tag and also as a href attribute.

A hacker may post malicious code and when another users will open the page with tweets that code may be executed by their browsers:

  1. " onload=alert(1) - if this appears in class attribute it will close the double quote and add a callback to onload event for this DOM.
  2. javascript:alert(1); - if this javascript URI appears in href attribute, browser will execute a code when user clicks.
  3. <script>alert(1)</script> - if this appears anywhere on the page the script will be executed.

The script may not just show an alert message, but for example do an AJAX call to another endpoint on this site (i.e. password change). This allows the attacker to perform any action as the user on this website.

Mitigation

When rendering templates Flask configures Jinja2 to automatically escape all values unless explicitly told otherwise. Auto-escaping is not enabled for all templates. The following extensions for templates trigger auto-escaping: .html, .htm, .xml, .xhtml. Templates loaded from a string will have auto-escaping disabled.

So because of the above exploit using code #3 will be escaped and never executed.

Code #1 gives me ERR_BLOCKED_BY_XSS_AUDITOR error in Chrome Version 65.0.3325.181. This browser behavior can be manipulated by the server with X-XSS-Protection header source

The only vulnerable part is href attribute in a tag. There are following options to mitigate

  • do not use user input with href
  • use {{ url_for('endpoint')}} when rendering template. This has additional benefit of automatically building proper URLs if endpoint path changes.
  • use Content-Security-Policy header in response:
@app.route('/', methods=['GET', 'POST'])
def tweet_feed():
    if request.method == 'POST':
        tweet = request.form['tweet']
        tweets.append(tweet)
    response = make_response(render_template('tweet_feed.html', tweets=tweets))
    response.headers['Content-Security-Policy'] = "default-src 'self'"
    return response

Content Security Policy (CSP) is security mechanism aimed at protecting from XSS and Clickjacking attacks. CSP allows you to specify trusted origins of loading resources such as Javascript, fonts, CSS and others. And also ban the execution of the built-in Javascript code. source

default-src 'self' in Content-Security-Policy header in server response instructs browser to load and execute scripts from the same source - your server, which is identified by protocol (http/https), hostname and port triplet. It also disables inline scripts like the one from malicious code #3.

Another types of XSS are the ones where malicious code is embedded in uploaded file (text, images). I’ll address them in File upload section later

CSRF

Cross-Site Request Forgery is an attack that allows an attacker to make requests to various sites under victim user. If the victim comes to a site containing a malicious code, a request is sent from her username to another service (social network) performing a destructive action. Unlike XSS the malicious code is stored on hacker controlled server where user is tricked to visit.

Exploit

Consider a online bank transfer page. You may access it by logging beforehand with the authentication information stored in cookie files.

<!doctype html>
<title>Hello from Flask</title>
<h1>Account balance {% raw %}${{ balance }}{% endraw %}</h1>

<h2>New transfer</h2>
<form method="post" action="/">
  <input type="text" name="destination_account" placeholder="Destination account"><br>
  <input type="number" name="amount"><br>
  <input type="submit" value="Transfer">
</form>

With Flaks app looking like this:

from flask import Flask, request, render_template
app = Flask(__name__)

account_balance = 1000


@app.route('/', methods=['GET', 'POST'])
def transfer():
    global account_balance
    if request.method == 'POST':
        transferred_amount = request.form['amount']
        if transferred_amount.isdigit():
            account_balance -= int(transferred_amount)
    response = make_response(render_template('transfer.html', balance=account_balance))
    return response


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5005, debug=True)

Now an attacker may trick you to follow a link to his website (I’m getting a lot of ‘Your transaction is approved, Follow this link for details’ spam messages recently). When you follow the link a page with the following script loads:

<form method="POST" action="http://0.0.0.0:5005/" id="hacker_form">
   <input type="text" name="destination_account" value="123123123"/>
   <input type="number" name="amount" value="1000"/>
   <input type="submit" value="Transfer">
</form>
<script>document.getElementById("hacker_form").submit()</script>

So when sending this form to bank app at http://0.0.0.0:5005/ authorization cookie will be also sent along. Thus user authorized bank to transfer $10000 to Account #123123123

Mitigation

I’ve used endpoint that changes the state of account only on POST request. That complicated hacker’s task a little bit as he needs user to visit his server with malicious code. If it was just GET user only needs to click the link without loading the script from hacker’s server.

Another security measure is to use tokens with each account state change endpoint. With every form user gets a random token (as a form’s hidden field) and when form is POSTed, the token is checked for validity and expiration. In Flask this is implemented in Flask-WTF plugin

Outline

  1. GET requests should not change the state of the system
  2. Check Origin and/or Referer request header in server code to match real server name
  3. Use CSRF-tokens

SQL Injection

SQL Injection occurs when attacker-controlled input is inserted into a SQL query without proper validation or sanitization. This often occurs when using string formatting or concatenation to build queries. An attacker may be able to read data for which they are not authorized, tamper with or destroy data, or possibly even write files or execute code on the database server. The impact is dependent on the exact scenario, but is generally quite severe.

Exploit

Consider bank transfer form the above example with the following Flask app (I’m using SQLAlchemy here and trying to put things simple, but usually it’s not how you manage database session in Flask app)

from flask import Flask, request, render_template, make_response, redirect, url_for
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine

app = Flask(__name__)
Base = declarative_base()
engine = create_engine('sqlite:///123.db', echo=True)

@app.route('/', methods=['GET', 'POST'])
def transfer():
    session = sessionmaker(bind=engine)()
    account_balance_query = session.execute('SELECT balance from accounts WHERE number=1111')
    account_balance = int(account_balance_query.fetchone()[0])
    if request.method == 'POST':
        transferred_amount = request.form['amount']
        destination_account = request.form['destination_account']
        session.execute('UPDATE accounts SET balance = balance - ' + transferred_amount + ' WHERE number=1111')
        session.execute('UPDATE accounts SET balance = balance + ' + transferred_amount + ' WHERE number=' + destination_account)
        session.commit()
        session.close()
        return redirect(url_for('transfer'))
    response = make_response(render_template('transfer.html', balance=account_balance))
    session.close()
    return response

I’ve tried to break all the rules here: building raw queries and concatenating query string with user input directly (I might have used .format as well). So when entering 2222;DROP DATABASE; in destination account input field I expected this particular line session.execute('UPDATE accounts SET balance = balance + ' + transferred_amount + ' WHERE number=' + destination_account) execute an update and then drop database. But it gave me that error sqlite3.Warning: You can only execute one statement at a time. and no changes were made to database. Not all drivers have that protection, while some allow multiple statement to be executed like so.

Mitigation

The proper way to avoid that is to use ORMs. In that case the Flask app will be like this:

from flask import Flask, request, render_template, make_response, redirect, url_for
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine

app = Flask(__name__)
Base = declarative_base()
engine = create_engine('sqlite:///123.db', echo=True)


class Account(Base):
    __tablename__ = 'accounts'
    id = Column(Integer, primary_key=True, autoincrement=True)
    number = Column(String(64))
    balance = Column(Integer)


@app.route('/', methods=['GET', 'POST'])
def transfer():
    session = sessionmaker(bind=engine)()
    account = session.query(Account).filter_by(number='1111').one()
    if request.method == 'POST':
        transferred_amount = int(request.form['amount'])
        account.balance -= transferred_amount
        destination_account_number = request.form['destination_account']
        destination_account = session.query(Account).filter_by(number=destination_account_number).one()
        destination_account.balance += transferred_amount
        session.commit()
        session.close()
        return redirect(url_for('transfer'))
    session.close()
    response = make_response(render_template('transfer.html', balance=account.balance))
    return response

Directory traversal

Directory traversal may happen when for example an attacker uploads a file with a filename like ../../../etc/passwd. If he guessed the number of .. right he might overwrite the file (given that uwsgi or Flask is running as root, but it may be any other file like ~/bash.rc). The mitigation is explained in official Flask docs here

Essentially you need to sanitize filenames from file upload form. Another advice is avoid using user provided filenames at all and generate your own (hash of the datetime/username/etc) and store them on a separate subdomain (more on it later)

XSS in uploaded files

This is not particularly related to Flask, but malicious javascript might appear not only in reflected output or stored in database (see general XSS attack), but it can also be embedded in images.For example, this blog post uses that code to embed javascript in GIF image file. CSP won’t help here - so the only way is to serve the images from separate subdomain (that’s what actually Facebook does)

Another thing related to files is how to you actually serve them. A pdf document uploaded by an attacker may have malicious code so if that file is server to another users it’s better to add Content-Disposition: attachment header to server response so browser will download it instead of showing right away.

Cookie files are used for a lot of things along with storing session or authentication data.There are some methods to protect them from exposing to an attacker

Secure

Cookies are sent with every request in clear text:

GET /index.html HTTP/1.1
Host: www.example.org
Cookie: session=XXXXXXX

It means that if an attacker that controls network equipment between you and server (or your ISP) can easily read it. Setting cookie secure flag will instruct browser to send a cookie only over protected HTTPS connection.

HTTP Only

HttpOnly flag will instruct browser to hide cookie content from javascript code. In case of XSS attack it will prevent an attacker from accessing sensitive data stored in it.

SameSite

In the CSRF example the critical part of the attack was that user’s cookie was send from attacker controlled site. It can be mitigated by setting SameSite=strict. Another option for that flag is ’lax’ which won’t allow sending cookies from another sites when doing requests other than GET.

Flask by default uses session cookie and its flags are set by configuring app:

app.config.update(
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE='Lax',
)

User defined cookies are set with response:

response.set_cookie('key', 'value', secure=True, httponly=True, samesite='Lax')

Have your Flask apps ever been hacked? Drop me a message on LinkedIn https://www.linkedin.com/in/smirnovam/

© Alexey Smirnov 2023