29 Nov 2020

CSRF and Cross-Origin Requests by Example

In this article, we will go over how a basic CSRF (cross-site request forgery) attack works and how a CSRF token prevents this type of attack.

We will also show how the browser’s same-origin policy can prevent undesired cross-origin access to resources such as the CSRF token.

The code for these examples is available on GitHub:

nestedsoftware / csrf

csrf/cors examples

How Cross-Origin Requests and CSRF Tokens Work

The examples below show how the browser's same-origin policy can prevent undesired cross-origin access to resources. It's important to understand that the browser enforces this policy on browser "reads", that is, on the responses sent back from the server to the browser (although the new samesite cookie behaviour recently implemented in Chrome, described further down, appears to be a welcome exception that greatly improves security).

These examples also show how an unguessable csrf token bound to the user's session can prevent cross-origin form submissions from succeeding (note: be sure to refresh the csrf token at login). In such cases, the form is actually submitted, along with the relevant authorization cookies, but there should be no way for a third-party to access the secret csrf token or to programmatically tamper with the user's form fields (also see clickjacking).

In addition the what is shown in the examples below, when possible, it is a good idea to make cookies secure and httponly as well as SameSite=strict.. Also (unrelated to this demo), remember to sanitize web inputs.

Start containers:

  • Run the "same-origin" docker container: $ ./run.sh
    • To view logs: $ docker logs --follow console-logging-server
  • Run the "cross-origin" docker container: $ ./run.sh console-logging-server-xorigin 8000
    • To view logs: $ docker logs --follow console-logging-server-xorigin

A Basic CSRF Attack

As of this writing (November 15, 2020), a basic csrf attack, even without csrf token protection, will no longer work by default in the Chrome browser (https://www.chromium.org/updates/same-site). The screenshot below shows what happens when we try:

CSRF Attack Fails in Chrome

The Chrome browser will not submit cookies via a cross-origin request by default. To support cross-origin cookie submission, the cookies must be marked with SameSite=None and Secure attributes. This basic demonstration does currently work in Firefox (version used for this example is 82.0.3), although Firefox is also apparently looking into implementing this restriction in the future.

  • To show that a normal form submission works (and to create the session cookie the malicious site will attempt to hijack): submit the form at http://localhost:3000/form
  • Next, to show that an unprotected cross-origin submission works, go to http://127.0.0.1:8000/submit_form_xorigin_no_csrf_protection.html (note: cookies don't distinguish different ports on the same domain, so this trick prevents clobbering the original cookie produced by the legitimate interaction with localhost)
  • Now, to show that a csrf token will prevent the above attack, go to http://127.0.0.1:8000/submit_form_xorigin_with_csrf_protection.html

Below is a screenshot showing the results from the 3 scenarios above (note that the 2 cross-origin requests that are forced when the user accesses the malicious web site on port 8000 cause the user's session cookie to be automatically submitted):

CSRF Attack Scenarios in Firefox

Cross-Origin Access Protections

Next, we can show some of the protections in place to prevent access to cross-origin resources. After all, if we are to rely on a csrf token to prevent csrf attacks, we need to make sure the attacker can't just get the token and proceed with the attack after all.

To demonstrate that same-origin access works, enter the following into the browser's address field (check browser console to make sure there are no errors):

  • http://localhost:3000/load_and_submit_form_with_fetch.html
  • http://localhost:3000/load_form_into_iframe.html
  • http://localhost:3000/load_form_into_iframe_no_embedding.html
  • http://localhost:3000/jquery_run_and_try_to_load_source.html

To demonstrate that cross-origin access will not work, enter the following into the browser's address field (check browser console for cross-origin error messages):

  • http://localhost:8000/load_and_submit_form_with_fetch.html
  • http://localhost:8000/load_form_into_iframe.html
    • Note: We can load the form with the csrf token into the iframe, and the user can submit the form successfully. However, our javascript cannot access or modify the contents of the form
  • http://localhost:8000/load_form_into_iframe_no_embedding.html
    • Note: With these headers, the browser will not allow the cross-origin form to be embedded in an iframe at all
  • http://localhost:8000/jquery_run_and_try_to_load_source.html
    • Note: Code in <script> tags can always be executed cross-origin, but we cannot inspect its source cross-origin

wireshark with http filter can be used to show that cross-origin requests are sent to the server and the responses are returned from the server to the browser. However, if CORS is not enabled on the server, the browser will block the relevant html or js code from reading the information returned from the server.

Set Up

These examples use a simple Express application running in a docker container. To get started, we need to run two web servers. We will consider the “same-origin” server to run on port 3000. The “cross-origin” server will run on port 8000. The idea here is that the cross-origin server serves code to the browser and this code then tries to access resources on the same-origin server - thus making a “cross-origin” request.

A “scheme/host/port tuple” is used to determine whether the destination for a request matches its origin.

To get started, let’s run our two servers:

  • Run the same-origin container: $ ./run.sh
  • View logs for same-origin server: $ docker logs --follow console-logging-server
  • Run the cross-origin container: $ ./run.sh console-logging-server-xorigin 8000
  • View logs for cross-origin server: $ docker logs --follow console-logging-server-xorigin

A Basic CSRF Attack

The idea here is that we induce a user to open a malicious web site. This web site will either get the user to submit a form to a site they have already logged in to, or may even trigger the submission automatically. Traditionally, the browser would send along any cookies, including ones used for authentication, as part of that submission. As long as the user was already logged into the site, this would allow the malicious web site to trigger actions on behalf of the user without their awareness. CSRF tokens have been the standard method to prevent so-called CSRF attacks.

As of this writing (November, 2020), a basic CSRF attack, even without CSRF token protection, will no longer work by default in the Chrome browser. The screenshot below shows what happens when we try:

CSRF Attack Fails in Chrome

For quite some time, the default behaviour has been to submit cookies automatically when a request against a given server is made, even if that request comes from code loaded from a different origin. However, the Chrome browser will no longer submit cookies via a cross-origin request by default. To support cross-origin cookie submission, the cookies must be marked with SameSite=None and Secure attributes.

The basic demonstration of a CSRF attack below does currently work in Firefox (version 82.0.3 used for this example), although Firefox is also apparently looking into implementing such a restriction in the future.

We will load a form from our cross-origin server on port 8000 and use JavaScript to submit that form to our server on port 3000:

<!DOCTYPE html>
<html>
  <head>
    <title>Submit form with JS (no csrf protection)</title>
    <script>
      document.addEventListener("DOMContentLoaded", function(event) {
        document.getElementById('hackedForm').submit();
      });
    </script>
  </head>
  <body>
    <form id="hackedForm" action="http://localhost:3000/save_no_csrf_protection" method="post">
    <label for="name">
    <input type="text" id="name" name="name" value="Hacked">
    <input type="submit" value="Save">
  </body>
</html>
  • To show that a normal form submission works (and to create the session cookie the malicious site will attempt to hijack): submit the form at http://localhost:3000/form
  • Next, to show that an unprotected cross-origin submission works, go to http://127.0.0.1:8000/submit_form_xorigin_no_csrf_protection.html (note: cookies don’t distinguish different ports on the same domain, so this trick prevents clobbering the original cookie produced by the legitimate interaction with localhost)
  • Now, to show that a CSRF token will prevent the above attack, go to http://127.0.0.1:8000/submit_form_xorigin_with_csrf_protection.html

Below is a screenshot showing the results from the 3 scenarios above (note that the 2 cross-origin requests that are forced when the user accesses the malicious web site on port 8000 cause the user’s session cookie to be automatically submitted):

CSRF Attack Scenarios in Firefox

We can see that in the 3rd case, even though the session cookie gets submitted by the attacker, they don’t have access to the CSRF token, so the form submission is rejected.

Cross-Origin Access Protections

Next, let’s take a look at some of the protections in place to prevent cross-origin access. After all, if we are to rely on a CSRF token to prevent CSRF attacks, we need to make sure the attacker can’t just get the token and proceed with the attack anyway.

To demonstrate that same-origin access works, enter the following into the browser’s address field (check the browser console to make sure there are no errors):

  • http://localhost:3000/load_and_submit_form_with_fetch.html
  • http://localhost:3000/load_form_into_iframe.html
  • http://localhost:3000/load_form_into_iframe_no_embedding.html
  • http://localhost:3000/jquery_run_and_try_to_load_source.html

Cross-Origin Form Load/Submission

The following URL shows that loading and automatically submitting a form cross-origin doesn’t work: http://localhost:8000/load_and_submit_form_with_fetch.html

The code uses javascript to load the form from port 3000 into the dom, then updates a form field and submits the form:

<!DOCTYPE html>
<html>
  <head>
    <title>Fetch and submit form with JS (try to get csrf token)</title>
    <script>
      fetch("http://localhost:3000/form")
      .then(r => r.text())
      .then(d => {
        const action = new DOMParser()
          .parseFromString(d, 'text/html')
          .forms[0]
          .getAttribute('action');
        const csrfToken = new DOMParser()
          .parseFromString(d, 'text/html')
          .forms[0]
          .elements['csrfToken']
          .value;

        const data = new URLSearchParams();
        data.append("name", "injected name");
        data.append("csrfToken", csrfToken);

        fetch('http://localhost:3000' + action, {
          method: 'POST',
          body: data
        })
        .then(r => console.log("status: ", r.status));
      })
      .catch(e => console.log(e));
    </script>
  </head>
  <body>
  </body>
</html>

Here is what happens:

Browser blocks cross-origin request

As we can see, the browser prevents the javascript from loading the form because it is a cross-origin request (we log an exception in the fetch call to the browser’s console: load_and_submit_form_with_fetch.html:30 TypeError: Failed to fetch).

It’s important to understand that the browser does issue the fetch request to load the form and the server does send the form back to the browser, including any CSRF token (note: the 404 response is just because the “favicon.ico” file is missing).

The wireshark trace for the fetch request is shown below:

wireshark trace of fetch request being sent

The wireshark trace for the response from the server is shown below:

wireshark trace of response to fetch request

However, the same-origin policy prevents this information from reaching the code that tries to access it.

Cross-Origin IFrame

Let’s see if cross-origin loading of a form into an iframe works: http://localhost:8000/load_form_into_iframe.html.

The HTML file loaded from the cross-origin server (port 8000) attempts to load the contents of the form at port 3000 into an iframe and to populate the contents of the form:

<!DOCTYPE html>
<html>
  <head>
    <title>IFrame Form Loader</title>
    <script>
      document.addEventListener("DOMContentLoaded", function(event) { 
        const iframe = document.getElementById("iframe");
        iframe.addEventListener("load", function() {
          try {
            const formField = iframe.contentWindow.document.getElementById("name");  
            if (formField) {
              formField.value = "filled by JS code";
            }
          } catch (e) {
            console.error(e);
          }
          try {
            const csrfToken = iframe.contentWindow.document.getElementById("csrfToken");
            if (csrfToken) {
              console.log("csrfToken", csrfToken.value);
            }
          } catch (e) {
            console.error(e)
          }
        });
      });
    </script>
  </head>
  <body>
    <iframe id="iframe" src="http://localhost:3000/form" title="iframe tries to load form - hardcoded to port 3000">
  </body>
</html>

The following wireshark trace shows that the request for the form is sent successfully:

load form into iframe cross-origin request is sent

The browser also receives the form successfully from the server:

Load form into iframe cross-origin browser received response

It’s interesting to note that the cross-origin script is able to successfully load the form into an iframe. However, the same-origin policy prevents the script from reading the CSRF token or populating the form with data:

Load form into iframe reading/writing not allowed

If the user fills out this form and submits it manually, it will work though, even when loaded cross-origin.

This feels dangerous to me. We can add some headers to prevent the browser from allowing the form to be embedded by a cross-origin request in the first place:

app.get('/form_no_embedding', (req, res) => {
  console.log({ url: req.url, method: req.method, headers: req.headers });
  res.header('X-Frame-Options', 'SAMEORIGIN');
  res.header('Content-Security-Policy', "frame-ancestors 'self'");
  res.render('simple_form', {csrfToken: req.session.csrfToken});
});

If we try the same technique on a form that has been protected by such headers, we see that the browser will not load the form into the iframe anymore. http://localhost:8000/load_form_into_iframe_no_embedding.html:

headers prevent cross-origin loading into iframe

Script Tags

Script tags are interesting, in that the browser won’t place restrictions on script execution. A script can include JavaScript code from another site, and that code will successfully execute. However, the page won’t be able to access the source code of that script. The following code successfully executes a bit of jQuery code loaded from the same-origin site:

<!DOCTYPE html>
<html>
  <head>
    <title>jQuery: running always works x-origin, but not accessing source</title>
    <script id="jq" type="text/javascript" src="http://localhost:3000/js/jquery-3.5.1.js"></script>
  </head>
  <body>
    <div id="execute_jquery"></div>
    <div id="jquery_source_code"></div>
    <script>
      $("#execute_jquery").html("<b>I work with same origin and cross origin!</b>");
    </script>
    <script>
      const script = document.getElementById("jq");
      const url = script.src;
      fetch(url)
      .then(r => r.text())
      .then(d => document.getElementById("jquery_source_code").innerHTML = d)
      .catch(error => console.log(error));
    </script>

  </body>
</html>

However, the cross-origin request, http://localhost:8000/jquery_run_and_try_to_load_source.html, cannot access the jQuery source code:

source code of script tag cannot be accessed cross-origin

When this same page is loaded from the same-origin server on port 3000, the entire source code of jQuery is displayed on the page:

source code of script tag cann be accessed same-origin

When it is a cross-origin request though, the browser does not allow it.

Conclusion

Hopefully this article has been helpful in clarifying how the browser’s same-origin policy works together with CSRF tokens to prevent CSRF attacks. It’s important to understand that the browser enforces this policy on browser “reads”, that is, on the responses sent back from the server to the browser.

Frankly, this approach of leaving it until the last moment to prevent malicious code from working strikes me as rather brittle. I welcome Chrome’s new samesite cookie behaviour mentioned earlier in the article. It seems much more secure. If all browsers implement this, perhaps in the future we can start getting away from needing such elaborate and error-prone protection measures.

As an example of the kind of complexity we have to deal with when working with CSRF tokens, should we refresh our CSRF tokens for each request, as recommended by OWASP, despite various problems this creates with the browser’s “back” button or with using multiple tabs? Or is it sufficient to set up the CSRF token at the session level? For the latter, be sure to refresh the csrf token at login.

Separately from the discussion of CSRF in this article, when possible, it is a good idea to make cookies secure and httponly as well as SameSite=strict. While it is unrelated to this article, also please always remember to sanitize web inputs to ward off XSS attacks.

The examples in this article are meant to illustrate the basic concept of how CSRF tokens work . Please don’t use the code in production. Instead, leverage a well-established library appropriate to the particular Web technology you are using.