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:
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):
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):
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):
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
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.
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.
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><formid="hackedForm"action="http://localhost:3000/save_no_csrf_protection"method="post"><labelfor="name"><inputtype="text"id="name"name="name"value="Hacked"><inputtype="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):
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):
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=>{constaction=newDOMParser().parseFromString(d,'text/html').forms[0].getAttribute('action');constcsrfToken=newDOMParser().parseFromString(d,'text/html').forms[0].elements['csrfToken'].value;constdata=newURLSearchParams();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:
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:
The wireshark trace for the response from the server is shown below:
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){constiframe=document.getElementById("iframe");iframe.addEventListener("load",function(){try{constformField=iframe.contentWindow.document.getElementById("name");if(formField){formField.value="filled by JS code";}}catch(e){console.error(e);}try{constcsrfToken=iframe.contentWindow.document.getElementById("csrfToken");if(csrfToken){console.log("csrfToken",csrfToken.value);}}catch(e){console.error(e)}});});</script></head><body><iframeid="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:
The browser also receives the form successfully from the server:
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:
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:
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:
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><divid="execute_jquery"></div><divid="jquery_source_code"></div><script>$("#execute_jquery").html("<b>I work with same origin and cross origin!</b>");</script><script>constscript=document.getElementById("jq");consturl=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:
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:
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.