Legitimate Cross-Site Request... a CORS and CSRF story (EN)
jeu. 20 avril 2017 Dan LousquiCORS and CSRF are two mechanisms built for Web Application protection. However, most of their implementations are incompatible with each other, and I haven't seen any documentation on this problem.
This is my journey to build a secure application, protected toward both CSRF and CORS attack.
Firstly, I'll explain what is CORS and CSRF, and how django
implement protections against it, then, I'll present the solution I designed to enable a legitimate Web Application performing Cross-Site Request toward a Web API, while protecting the Web API against CSRF and CORS.
Some elements of context
Web Application, as developed today, are awesome! You can develop a whole application with a real separation between the logic and the interface.
In my example, we will use the following use case:
- A Web API, developed in a
django
application, that handles the whole logic of the application (login, registration, CRUD, ...) - A Web UI, developed with any
javascript
swag-work (react
,angular
,vue.js
, ...)
Both stacks are hosted on different servers, different hostnames, let say https://awesome_web_api/
and https://awesome_web_ui/
.
Users navigate to https://awesome_web_ui/
to use the application, and AJAX
queries are performed toward https://awesome_web_api/
to interact with the service.
Thus far, nothing fancy, I don't think there is any exotic requirements here... however, if you want to secure this flow... it can be really painful! Let's see why.
What is CORS ?
CORS
stands for Cross-Origin Resource Sharing
. The main problem behind it, is that you don't want any website to be able to let their user performs AJAX
queries toward your website.
Let's take an example. Imagine https://awesome_web_api/
has an API endpoint such as, if an authenticated user goes to https://awesome_web_api/users/me
he can retrieve details about him, for example:
GET /users/me
200 OK
{
"name":"Blusky",
"email":"blusky@awesome.io",
"score":42
}
Now, your main competitor have a website called https://awful_evil/
, and wants to steal your users. If on his website he performs an AJAX
request toward https://awesome_web_api/
, there is a security mechanism implemented inside the web browser that perform a preliminary request toward https://awesome_web_api/
with the following content :
OPTION /users/me
Origin: https://awful_evil/
So now, the server should respond that request. If (and only if) the response header contains Access-Control-Allow-Origin: https://awful_evil/
, the original request will then be sent. If not, the web browser will trigger a security warning in console logs.
Obviously, default settings will forbid any Origin
, but in our case, we want to add https://awesome_web_ui/
to allowed origins list for https://awesome_web_api/
.
There are also some other subtleties that will have some meanings later:
Access-Control-Allow-Header
let you specify whichheaders
can be read by JavascriptAccess-Control-Allow-Credentials
let you keep a cookie jar, in order to keep a session alive
What is CSRF ?
CSRF
stands for Cross-Site Request Forgery
. With CORS
, we saw how to prevent AJAX
queries to be performed from other website.
However, there is other ways than AJAX
to perform requests.
In this example, imagine https://awesome_web_api/
got an API endpoint such as, if an authenticated user goes to https://awesome_web_api/messages/delete
he can perform operations, for example:
POST /messages/delete
message_id=1
200 OK
{
"status":"ok"
}
For example, you can create the following form on https://awful_evil/
:
<form method='POST' action='https://awesome_web_api/messages/delete' id="csrf-form">
<input type='hidden' name='message_id' value='1'>
</form>
<script>
document.getElementById("csrf-form").submit()
</script>
When a user navigates to https://awful_evil/
, this form will automatically be submitted, and your first message will be deleted.
The main problem is, the attacker can "predict" the whole content of your form.
Sadly, for such mechanism, CORS
are not protecting your website, and no "built-in" mechanism exists for this. You have to implement what is called a CSRF
token.
There is are renowned ways to do this:
- For each form created on your website, generate a random token that will be added in a hidden field in the view. When the form is submitted, the application must check that given token corresponds to submitted form.
- If no CSRF cookie exists, generate one, for CSRF protected end points, a CSRF header must be set, and the value of the CSRF header is the same as the CSRF cookie, the request is considered as legitimate.
The first method cannot be used in our case, because https://awesome_web_ui/
does not use HTML forms, but performs AJAX
requests. Therefore, the second choice will be used... and that's fine, it's the default django
behavior.
How to implement it with django
?
In our example, let's say our django project is called awesome
, and our application is called awesomeapp
.
CSRF
are handled by default, for all authenticated POST
request, with the django.middleware.csrf.CsrfViewMiddleware
middleware.
CORS
whitelisting is not settable in vanilla django
, we will use the django-cors-headers
dependency that can be installed with
pip install django-cors-headers
Then, the following configuration enables the protection inside the application:
/awesome/awesome/settings.py
CORS_ORIGIN_WHITELIST = ('http://awesome_web_ui/',)
CORS_ALLOW_CREDENTIALS = True
INSTALLED_APPS = [
...
'corsheaders',
'myapp',
...
]
MIDDLEWARE = [
...
'corsheaders.middleware.CorsMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
...
]
So now that everything's up and ready, why the fuss ?
That's exactly where... things do not seem to work as expected. Here's why.
So in http://awesome_web_ui/
, I can perform operations on the http://awesome_web_api
, and the Web API server allows those requests.
Let's re-use our previous example and imagine that an authenticated user able to list messages wants to delete the message message_id=1
The web browser will send the following request to the API:
POST /messages/delete
message_id=1
But beforehand, the browser will check the CORS
and send the following request
OPTION /messages/delete
Origin: https://awesome_web_ui/
As awesome_web_ui
is whitelisted, the server give a 200 OK
response, so the POST
request is sent.
However, the following response is given:
403 Forbidden
CSRF verification failed
And... indeed, we did not send any CSRF token, so that response is normal... We need to send the CSRF token to prove that our request is legitimate.
But... Where is the CSRF Token ? In the csrfcookie
! Let's check csrfcookie
!
But it's not possible :-( Indeed, on the https://awesome_web_ui/
context, this cookie is never set, and we cannot execute Javascript code on the http://awesome_web_api
context from https://awesome_web_ui/
in order to retrieve it.
So how ?
Solution
I did not see ANY documentation on this subject. If there is a standard for such situation, I would love to hear it.
What I did, is create a new header in the response of each request, that contains the CSRF token value.
This way, the CSRF Token is on two different places: the cookie jar, and this new header (called X-CSRFToken
).
I needed to add Access-Control-Allow-Header: X-CSRFToken
in request's response, so CORS
allows Javascript to read this header.
This way, our previous request:
POST /messages/delete
message_id=1
Send the following response:
403 Forbidden
X-CSRFToken: 65az231fq65az4
CSRF verification failed
So now, the Javascript can retrieve the CSRF Token value, and generate the following request:
POST /messages/delete
X-CSRFToken: 65az231fq65az4
message_id=1
That will trigger the following response:
200 OK
{
"status":"ok"
}
Wow.... finally!
Django implementation
In order to implement that in a Django
application, we need to create a middleware that will retrieve CSRF Token, and add it to requests:
/awesome/awesomeapi/utils.py
def csrf_header_middleware(get_response):
def middleware(request):
csrf_token = ""
if "HTTP_COOKIE" in request.META:
http_cookies = request.META['HTTP_COOKIE'].split("; ")
for http_cookie in http_cookies:
if http_cookie.startswith("csrftoken="):
csrf_token = http_cookie.split("=")[1]
response = get_response(request)
if 'CSRF_COOKIE' in request.META:
csrf_token = request.META['CSRF_COOKIE']
response.__setitem__("X-CSRFToken", csrf_token)
return response
return middleware
We need to add this middleware just before the Django CsrfViewMiddleware
:
/awesome/awesome/settings.py
CORS_EXPOSE_HEADERS = ['X-CSRFToken']
MIDDLEWARE = [
...
'awesome.awesomeapi.utils.csrf_header_middleware',
'django.middleware.csrf.CsrfViewMiddleware',
...
]
That's all folks :-)