Flevo CFD

Cross-Site Request Forgery (CSRF): A Simple Explanation

Cross-Site Request Forgery (CSRF) is a type of attack that occurs when a malicious website tricks a user into submitting a request to a website that they are authenticated to. The attacker can use this request to perform actions on the website on behalf of the user, such as changing their password, deleting data, etc.

Since the browser automatically sends cookies with every request to a website, the request will be authenticated using the user’s cookies. This means that the website will treat the request as if it came from the user, even though it was initiated by the attacker.

A Simple Application to Demonstrate CORS

We’re going to setup a simple application to demonstrate CSRF. We will have a flask application running on https://devbox1.com as a legit website and another flask application running on https://devbox2.com as an attacker’s website.

We’re using linux hosts file to serve the application under a domain name. hosts file is a handy way to map domain names to IP addresses. In a production environment, you would use a DNS server to map domain names to IP addresses.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
sudo bash -c 'echo "127.0.0.1 devbox1.com" >> /etc/hosts'
sudo bash -c 'echo "127.0.0.1 devbox2.com" >> /etc/hosts'

```bash
sudo apt install nginx

echo <<EOF >| /etc/nginx/sites-available/devbox1.com
server {
    listen 80;
    server_name devbox1.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}
EOF

echo <<EOF >| /etc/nginx/sites-available/devbox2.com
server {
    listen 80;
    server_name devbox2.com;

    location / {
        proxy_pass http://127.0.0.1:4000;
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/devbox1.com /etc/nginx/sites-enabled/devbox1.com
sudo ln -s /etc/nginx/sites-available/devbox2.com /etc/nginx/sites-enabled/devbox2.com

We can make another step and create a self-signed certificate for the domains and serve the applications over HTTPS. This is not needed for the demonstration.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
sudo apt install mkcert

mkcert -key-file devbox1_key.pem -cert-file devbox1_cert.pem devbox1.com
mkcert -key-file devbox2_key.pem -cert-file devbox2_cert.pem devbox2.com

echo <<EOF >| /etc/nginx/sites-available/devbox1.com
server {
    listen 443 ssl;
    server_name devbox1.com;

    ssl_certificate /path/to/devbox1_cert.pem;
    ssl_certificate_key /path/to/devbox1_key.pem;

    location / {
        proxy_pass http://127.0.0.1:3000
    }
}
EOF

echo <<EOF >| /etc/nginx/sites-available/devbox2.com
server {
    listen 443 ssl;
    server_name devbox2.com;

    ssl_certificate /path/to/devbox2_cert.pem;
    ssl_certificate_key /path/to/devbox2_key.pem;

    location / {
        proxy_pass http://127.0.0.1:4000
    }
}
EOF
1
sudo systemctl restart nginx

devbox1

1
2
3
4
5
mkdir devbox1
cd devbox1
python3 -m venv venv
source venv/bin/activate
pip install flask
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# main.py
from flask import Flask, request, session, redirect, url_for


app = Flask(__name__)

app.secret_key = 'A0Zr98j/3yX R~XHH!jmN]LWX/,?RT'

@app.route('/')
def index():
    if 'username' in session:
        return f'Logged in as {session["username"]}'
    return 'You are not logged in'


@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        session['username'] = request.form['username']
        return redirect(url_for('index'))
    return '''
        <form method="post">
            <p><input type=text name=username>
            <p><input type=submit value=Login>
        </form>
    '''
1
flask --app main run -p 3000 --reload

devbox2

1
2
3
4
5
mkdir devbox12
cd devbox2
python3 -m venv venv
source venv/bin/activate
pip install flask
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from flask import Flask


app = Flask(__name__)

@app.route("/bait")
def bait():
    return '''
        <script>
            fetch("https://devbox1.com/", {
                credentials: 'include',
            }).then(res => res.text()).then(console.log);
        </script>
    '''
1
flask --app main run -p 4000 --reload

The login endpoint will set a session cookie with the username. browsers will automatically send the cookies with every request. The cookie’s SameSite attribute is determines this behavior.

What’s a Cookie? A cookie is a small piece of data that a server sends to the browser. The browser stores the cookie and sends it back with every request to the server. Cookies are used to store user information, so the server can identify the user and provide a personalized experience.

Let’s open https://devbox1.com/login in the browser and submit a username.

If the site doesn’t load try the following

  • Make sure the domain is resolved to the correct IP address. You can check this by running ping devbox1.com in the terminal. Note that DNS utilities like dig don’t use the hosts file unlike ping and browsers.
  • Hard refresh the page. If you’re using Chrome, Press Ctrl + F5.
  • If you’re using Crome, go to chrome://net-internals/#dns and clear the DNS cache. Browsers have their own DNS cache to speed up the loading of websites.

Then open https://devbox2.com/bait/ in the same browser.

The malicious website will send a request to the legit website. The browser will automatically send the cookies for devbox1.com with the request because the credentials flag is set to true. The legit website will treat the request as if it came from the user, even though it was initiated by the attacker.

You should get a CORS error in the console. This is because the browser blocks the request from https://devbox2.com to https://devbox1.com due to the same-origin policy. You can take a look at my previous post on CORS to understand more about it.

1
2
3
4
Access to fetch at 'https://devbox1.com/' from origin 'https://devbox2.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is
present on the requested resource. If an opaque response serves your needs,
set the request' mode to 'no-cors' to fetch the resource with CORS disabled.

To get around this, we need to enable CORS on the server running on https://devbox1.com. We need to set Access-Control-Allow-Origin to include the origin of the request from https://devbox2.com which is the same value. The * value can also be used to allow requests from any origin.

Let’s use flask-cors library to do this.

1
pip install flask-cors
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# main.py
from flask import Flask, request, session, redirect, url_for
from flask_cors import CORS


app = Flask(__name__)

app.secret_key = 'A0Zr98j/3yX R~XHH!jmN]LWX/,?RT'
app.config['CORS_SUPPORTS_CREDENTIALS'] = True
app.config['CORS_SEND_WILDCARD'] = True
app.config['CORS_ORIGINS'] = '*'

CORS(app)

@app.route('/')
def index():
    if 'username' in session:
        return f'Logged in as {session["username"]}'
    return 'You are not logged in'


@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        session['username'] = request.form['username']
        return redirect(url_for('index'))
    return '''
        <form method="post">
            <p><input type=text name=username>
            <p><input type=submit value=Login>
        </form>
    '''

This config raises an error

1
2
ValueError: Cannot use supports_credentials in conjunction withan 
origin string of '*'. See: http://www.w3.org/TR/cors/#resource-requests

The reason being based on the CORS specification, you can’t set the Access-Control-Allow-Origin header to * when Access-Control-Allow-Credentials is set to true.

Let’s assume the legit website owner wants to allow requests from any origin and they set the header to the request origin. For our demonstration, we can set it to https://devbox2.com.

1
app.config['CORS_ORIGINS'] = 'https://devbox2.com'

Now if you open https://devbox2.com/bait in the browser, you should see the response from https://devbox1.com in the console.

1
Logged in as {whatever username you entered}

As you can see, the malicious website was able to send a request to the legit website on behalf of the user. This is a CSRF attack.

This was a simple request that did’t change anything on the server. Let’s demonstrate a more dangerous CSRF attack.

Let’s define a route that sends an invitatioin to a user.

1
2
3
4
5
6
# devbox1
@app.route('/invite', methods=['POST'])
def invite():
    email = request.form.get('email')
    send_invitation(email)
    return 'Invitation sent'

Add the following code to the attacker’s application.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@app.route("/bait2")
def bait2():
    return '''
        <script>
            const formData = new FormData();
            formData.append('email', 'attacker@gmail.com');

            fetch("https://devbox1.com/invite", {
                credentials: 'include',
                method: 'POST',
                body: formData,
            }).then(response => response.text()).then( data => { console.log(data) });
        </script>
    '''

If you open https://devbox2.com/bait in the browser, the attacker’s website will send an invitation on behalf of the user.

How to Prevent CSRF

There’s no foolproof way to prevent CSRF attacks, but there are some measures you can take to reduce the risk.

Use Lax/Strict Cookies

The SameSite attribute determies when the browser should send cookies with a request. It has three possible values

  • Strict: This means that the cookies will not be sent in cross-site requests. the browser first checks the origin of the request and only sends the cookies if the origin matches the current origin in the address bar.

  • Lax: The browser sends the cookies only when the user navigates to the website from a link or types the URL in the address bar. This means that the cookies will not be sent if the user submits a form or uses a script to send a request. Chrome sets the SameSite attribute to Lax if it’s not set.

  • None: The browser sends the cookies with every request, regardless of the origin. for example in iframe requests, images, and scripts.

Check out the MDN for more information on the SameSite attribute.

Lax is a good compromise between security and usability and is recommended for most websites.

In our first exmaple, if the legit website sets the SameSite attribute to Strict or Lax, the browser will not send the cookies in which the request will fail. (If you want to try this, don’t forget to clear the cookies in the browser to see the effect.)

To overcome this, the attacker would trick the user to click on a link that sends a GET request to the legit website which the browser will send cookies with SameSite set to Lax.

So a recommendation here is to make sure safe methods like GET do not change a state on the server and are side effect free.

The other layer of security is to use CSRF tokens.

Use CSRF Tokens

A CSRF token is a unique value that verifies that the request was initiated by the website and not by an attacker.

The application generates a token, includes it in the form and stores it in a cookie. When the form is submitted, the token is sent as a form field or a header. the server verifies the token before processing the request with the same token stored in the cookie. since the attacker’s website can’t read the token from the cookie due to the same-origin policy, they can’t include it in the form.

The cookie policy is aligned with the same-origin policy. The browser should block any access to document.cookie from sites with different origins, even if they share the same cookie domain. For instance, apple.dev.com should not be able to access document.cookie from banana.dev.com, even though the cookie domain is *.dev.com.

Every web framework has its own way of generating and verifying CSRF tokens. For example Flask has a package called flask-wtf that can be used to generate and verify CSRF tokens.

1
pip install flask-wtf
1
2
3
4
5
6
# main.py
from flask_wtf.csrf import CSRFProtect
...

csrf = CSRFProtect(app)
...

If you open https://devbox2.com/bait2 in the browser, the request will fail with a 400 Bad Request error.

1
2
3
4
5
<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>The CSRF token is missing.</p>

This library by default protects all POST, PUT, PATCH, and DELETE requests.

Check out the OWASP for more information on CSRF attacks.