Cross-Site Request Forgery (CSRF)

Submitting data-altering requests blindly from your domain on the client-side. Cookies are automatically sent, often requiring CSRF tokens as protection

Description

Websites need to be able to access their own sensitive content, while malicious websites should not be able to access that same data from another site. To make this possible, browsers implement some same-origin and same-site policies. These either allow or deny an action based on the origins of the request. As you can read in the table below, same-site is generally more allowing than same-origin:

Request fromRequest toSame-site?Same-origin?

example.com

example.com

Yes

Yes

app.example.com

other.example.com

Yes

No: mismatched domain name

example.com

example.com:8080

Yes

No: mismatched port

example.com

example.co.uk

No: mismatched eTLD

No: mismatched domain name

https://example.com

http://example.com

No: mismatched scheme

No: mismatched scheme

One feature that uses the same-origin policy is Cross-Origin Resource Sharing (CORS). This prevents an attacker from requesting a page from a website on a user's behalf and being able to read the response content. If this were not the case, any website could steal secrets from any other website by simply requesting them. This policy ensures certain response headers are explicitly set to allow this cross-origin resource sharing.

A different feature that uses the same-site policy is Cookies. On many websites cookies are everything that authenticates the user. If a request includes the session cookie of a user, they are allowed to perform actions on their account. Simple as that. To make sure malicious websites cannot simply recreate a <form> and send it automatically to change a password, for example, these requests are checked to be same-site (see table above). If the origins are not same-site the cookies will not be sent.

In the early web days, this SameSite did not exist for cookies. Nowadays it is an attribute on cookies that may be None (no protections), Lax (default, some protections) or Strict (most protections). This value is important to know as it decides what kind of cross-site requests will be authenticated. The table above shows that at least any subdomain on any port will bypass same-site protections because it is considered same-site. This means that any Cross-Site Scripting (XSS) vulnerability on such a website may lead to you being able to make authenticated requests!

However, the reality is slightly more complicated. Because these rules are so lax, most sites implement their own protection: CSRF Tokens. These are extra fields on a form that are randomly generated, but attached to the user's session. Whenever a form is submitted, the extra CSRF token field is validated to match the session and only then will it be considered authenticated. A malicious site won't know this randomly generated token and therefore cannot make a fake request that includes it. This is assuming however that 1. This token is implemented, 2. This token is generated securely, and 3. This token is unique per user. (learn more)

Examples

To get a more practical idea of these protections, here are some examples of what is and isn't allowed in modern browsers. Firstly, some practical examples of how an attacker's site can send POST data to another site if it is misconfigured:

Using <form>
<form id=form action="https://example.com/reset_password" method="POST" enctype="application/x-www-form-urlencoded">
    <input type="text" name="password" value="hacked">
</form>
<script>form.submit();</script>
Using fetch()
<script>
    fetch('https://example.com/reset_password', {
        method: 'POST',
        mode: 'no-cors',  // Prevent preflight request
        credentials: 'include',  // Include cookies if allowed
        headers: {  // Parse body as form submission
            "Content-Type": "application/x-www-form-urlencoded"
        },
        body: 'password=hacked',
    })
</script>

Here both methods can achieve the same requests, only with the fetch() method you can completely control the body data while using a <form> this is done for you depending on the Content-Type. This type can be changed to one of three values, which all have different formats. The text/plain type may be interesting if a server expects the application/json type which is normally impossible, but also accepts this as an alternative. Here are all three:

Content-Type: application/x-www-form-urlencoded
name1=value1&name2=value2

Content-Type: multipart/form-data
------WebKitFormBoundaryS9COBpBA97fjAsLJ
Content-Disposition: form-data; name="name1"

value1
------WebKitFormBoundaryS9COBpBA97fjAsLJ
Content-Disposition: form-data; name="name2"

value2
------WebKitFormBoundaryS9COBpBA97fjAsLJ--

Content-Type: text/plain
name1=value1
name2=value2

Strict

As mentioned earlier, the SameSite protection only prevents cross-site requests. If you can create a fake form or have javascript execution on a sibling domain or different port, this bypasses the restriction.

If this is not possible, there is another interesting method. It's impossible to send an authenticated request from your own site, so why not try to send a request from the site you are already targeting? Any requests like client-side redirects will be authenticated because you are on the same site. For this to work the target endpoint that you want to execute, such as /reset_password, will need to allow GET requests with parameters. In a very flexible framework, this behavior might be common as query and body parameters are merged.

Take the following gadget, which allows an unauthenticated client-side redirect using a parameter:

Client-side redirect
// Redirect '?postId=42' to '/post/42'
const postId = new URL(location).searchParams.get("postId");
location = "/post/" + postId;

Note that while this is in a GET response, an unauthenticated POST response might also have a gadget like this to abuse. We can send such a request using the <form> technique from above.

This gadget can be abused because after redirecting to this location from our malicious site, the next redirect will be authenticated as it is coming from the same site. Using a directory traversal sequence in the ?postId= query parameter we can make it redirect to the vulnerable state-changing GET endpoint that was our initial target, and it will be authenticated with Cookies:

CSRF URL
https://example.com/post?postId=../reset_password%3Fpassword%3Dhacked

Lax (default)

This default SameSite value is by far the most common. It sits in the middle of None and Strict as it allows some specific behaviors to send cookies, but not others:

  1. A top-level redirect using the Location: response header or location= in JavaScript. Only for GET requests

  2. Restrictions are only applied after 2 minutes from setting the cookie. This means that in the small window of time, any POST CSRF is possible as if it were None

You may be able to abuse this behavior if you find a state-changing GET request, or can trick the server into thinking it is a POST request with with backends like Symfony that have an extra ?_method= parameter that can be set to POST in a regular GET request:

https://example.com/reset_password?_method=POST&password=hacked

The other time-based behavior has a small chance of a victim just having logged in being exploitable. This is pretty unlikely, but a more powerful way to use this is if the site allows resetting the cookie. When it is set again by opening a new tab from your site, for example, the timer is also reset and a CSRF is possible.

<form id=form action="https://example.com/reset_password" method="POST">
    <input type="text" name="password" value="hacked">
</form>
<p>Click anywhere on the page</p>
<script>
    window.onclick = () => {
        // Reset cookie
        window.open('https://example.com/login');
        setTimeout(() => {
            // After it has been reset, CSRF within the 2-minute window
            form.submit();
        }, 5000);
    }
</script>

None

With this SameSite attribute, the cookie is treated as before SameSite was implemented. This means any techniques like the <form> or fetch() will work and send cookies with any request method. In such cases, you should check if any CSRF tokens are required; if not, there's a good chance you can make any victim send any state-changing request when they visit your site.

Last updated