This article looks at how to prevent CSRF attacks in Flask. Along the way, we'll look at what CSRF is, an example of a CSRF attack, and how to protect against CSRF via Flask-WTF.
Contents
What is CSRF?
CSRF, which stands for Cross-Site Request Forgery, is an attack against a web application in which the attacker attempts to trick an authenticated user into performing a malicious action. Most CSRF attacks target web applications that use cookie-based auth since web browsers include all of the cookies associated with a particular domain with each request. So when a malicious request is made from the same browser, the attacker can easily make use of the stored cookies.
Such attacks are often achieved by tricking a user into clicking a button or submitting a form. For example, say your banking web app is vulnerable to CSRF attacks. An attacker could create a clone of your banking web site that contains the following form:
<form action="https://centralbank.com/api/account" method="POST">
<input type="hidden" name="transaction" value="transfer">
<input type="hidden" name="amount" value="100">
<input type="hidden" name="account" value="999">
<input type="submit" value="Check your statement now">
</form>
The attacker then sends you an email that appears to come from your bank -- cemtralbenk.com instead of centralbank.com -- indicating that your bank statement is ready to view. After clicking the link in the email, you're taken to the malicious web site with the form. You click the button to check your statement. The browser will then automatically send the authentication cookie along with the POST request. Since you're authenticated, the attacker will be able to perform any action that you're allowed to do. In this case, $100 is transferred from you account to account number 999.
Think of all the spam emails you receive daily. How many of them contain hidden CSRF attacks?
Flask Example
Next, let's look at an example of a Flask app that's vulnerable to CSRF attacks. Again, we'll use the banking web site scenario.
That app has the following features:
- Login form that creates a user session
- Account page that displays account balance and a form to transfer money
- Logout button to clear the session
It uses Flask-Login for handling auth and managing user sessions.
You can clone down the app from the csrf-flask-insecure branch of the csrf-example repo. Follow the directions on the readme for installing the dependencies and run the app on http://localhost:5000:
$ python app.py
Make sure you can log in with:
- username:
test
- password:
test
After logging in, you'll be redirected to http://localhost:5000/accounts. Take note of the session cookie:
The browser will send the cookie with each subsequent request made to the localhost:5000
domain. Take note of the route associated with the account page in app.py:
@app.route("/accounts", methods=["GET", "POST"])
@login_required
def accounts():
user = get_user(current_user.id)
if request.method == "POST":
amount = int(request.form.get("amount"))
account = int(request.form.get("account"))
transfer_to = get_user(account)
if amount <= user["balance"] and transfer_to:
user["balance"] -= amount
transfer_to["balance"] += amount
return render_template(
"accounts.html",
balance=user["balance"],
username=user["username"],
)
Nothing too complex here: On a POST request, the provided amount is subtracted from the user's balance and added to the balance associated with the provided account number. When the user is authenticated, the bank server essentially trusts the request from the browser. Since this route handler isn't protected from a CSRF attack, an attacker can exploit this trust by tricking someone into performing an operation on the bank server without their knowledge. This is exactly what the hacker/index.html pages does:
<form hidden id="hack" target="csrf-frame" action="http://localhost:5000/accounts" method="POST" autocomplete="off">
<input type="number" name="amount" value="2000">
</form>
<iframe hidden name="csrf-frame"></iframe>
<h3>You won $100,000</h3>
<button onClick="hack();" id="button">Click to claim</button>
<br>
<div id="warning"></div>
<script>
function hack() {
document.getElementById("hack").submit();
document.getElementById("warning").innerHTML="check your bank balance!";
}
</script>
You can serve up this page on http://localhost:8002 by navigating to the project directory and running the following command in a new terminal window:
$ python -m http.server --directory hacker 8002
Other than the poor design, nothing seems suspicious to ordinary eyes. But behind the scenes, there's a hidden form that executes in the background removing all the money from the user's account.
An attacker could email a link to this page disguised as some sort of prize giveaway. Now, after opening the page and clicking the "Click to claim" button, a POST request is sent to http://localhost:5000/accounts that exploits the trust established between the bank and the web browser.
How to Prevent CSRF?
CSRF attacks can be prevented by using a CSRF token -- a random, unguessable string -- to validate the request origin. For unsafe requests with side effects like an HTTP POST form submission, you must provide a valid CSRF token so the server can verify the source of the request for CSRF protection.
CSRF Token Workflow
- The client sends a POST request with their credentials to authenticate.
- If the credentials are correct, the server generates a session and CSRF token.
- The request is sent back to the client, and the session is stored in a cookie while the token is rendered in a hidden form field.
- The client includes the session cookie and the CSRF token with the form submission.
- The server validates the session and the CSRF token and accepts or rejects the request.
Let's now see how to implement CSRF protection in our example app using the Flask-WTF extension.
Start by installing the dependency:
$ pip install Flask-WTF
Next, register CSRFProtect globally in app.py:
from flask import Flask, Response, abort, redirect, render_template, request, url_for
from flask_login import (
LoginManager,
UserMixin,
current_user,
login_required,
login_user,
logout_user,
)
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config.update(
DEBUG=True,
SECRET_KEY="secret_sauce",
)
login_manager = LoginManager()
login_manager.init_app(app)
csrf = CSRFProtect()
csrf.init_app(app)
...
Now, by default, all POST, PUT, PATCH, and DELETE methods are protected against CSRF. Take note of this. You should never perform a side effect, like changing data in the database, via a GET request.
Next, add the hidden input field with the CSRF token to the forms.
templates/index.html:
<form action='/' method='POST' autocomplete="off">
<input type='text' name='username' id='email' placeholder='username'/>
<input type='password' name='password' id='password' placeholder='password'/>
<input type='submit' name='submit' value='login'/>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
templates/accounts.html:
<h3>Central bank account of {{username}}</h3> <a href="/logout">logout</a>
<br><br>
<p>Balance: ${{balance}}</p>
<form action="/accounts" method="POST" autocomplete="off">
<p>Transfer Money</p>
<input type="text" name="account" placeholder="accountid">
<input type="number" name="amount" placeholder="amount">
<input type="submit" value="send">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
That's it. This will take care of CSRF for you. Now, let's see if this prevents the attack. Run both servers again. Log in to the banking app, and then try to the "Click to claim" button. You should see a 400 error:
What happens if you add the same hidden field to the form in hacker/index.html?
<form hidden id="hack" target="csrf-frame" action="http://localhost:5000/accounts" method="POST" autocomplete="off">
<input type="number" name="amount" value="2000">
<input type="number" name="account" value="2">
<input type="hidden" name="csrf_token" value="123">
</form>
It should still fail with a 400. We have successfully prevented the CSRF attack.
CORS and JSON APIs
For JSON APIs, having a properly configured Cross-Origin Resource Sharing (CORS) policy is important, but it does not in itself prevent CSRF attacks. In fact, it can make you more vulnerable to CSRF if CORS is not correctly configured.
For preflight requests CORS policies define who can and cannot access particular resources. Such requests are triggered by the browser when XMLHttpRequest or Fetch are used.
When a preflight request is triggered, the browser sends a request to the server asking for the CORS policy (allowed origins, allowed request types, etc.). The browser then checks the response against the original request. If the request doesn't meet the requirements, the browser will reject it.
Simple requests, meanwhile, like a POST request from a browser-based form submission, don't trigger a preflight request, so the CORS policy doesn't matter.
So, if you do have a JSON API, limiting the allowed origins or eliminating CORS altogether is a great way to prevent unwanted requests. You don't need to use CSRF tokens in that situation. If you have a more open CORS policy with regard to origins, it's a good idea to use CSRF tokens.
Conclusion
We've seen how an attacker can forge a request and perform operations without the user's knowledge. As browsers become more secure and JSON APIs are used more and more, CSRF is fortunately becoming less and less of a concern. That said, in the case of a simple request like a POST request from a form, it's vital to secure route handlers that handle such requests especially when Flask-WTF makes is so easy protect against CSRF attacks.