Understanding CORS
Martin Splitt
Posted on November 12, 2019
This is a cross-posting from my blog
TL;DR
- The browser enforces the Same-origin policy to avoid getting responses from websites that do not share the same origin.
- The Same-origin policy does not prevent requests being made to other origins, but disables access to the response from JavaScript.
- CORS headers allow access to cross-origin responses.
- CORS together with credentials require caution.
- CORS is a browser-enforced policy. Other applications aren't affected by it.
Our example
I will only show the request handling code here, but the full example is available on Github.
Let's start with an example. Say we have an amazing website with a public API available at http://good.com:8000/public
:
app.get('/public', function(req, res) {
res.send(JSON.stringify({
message: 'This is public'
}));
})
We also have a simple login feature, where users enter a shared secret and a cookie is set, identifying them as authenticated:
app.post('/login', function(req, res) {
if(req.body.password === 'secret') {
req.session.loggedIn = true
res.send('You are now logged in!')
} else {
res.send('Wrong password.')
}
})
We use this to protect some private data we made available to our users at /private
.
app.get('/private', function(req, res) {
if(req.session.loggedIn === true) {
res.send(JSON.stringify({
message: 'THIS IS PRIVATE'
}))
} else {
res.send(JSON.stringify({
message: 'Please login first'
}))
}
})
Requesting our API via AJAX from other domains
Now our API isn't particularly well-designed or fancy, but we could allow others to fetch data from our /public
URL. Say, our API lives at good.com:300/public
and our client is hosted on thirdparty.com
, the client might run the following code:
fetch('http://good.com:3000/public')
.then(response => response.text())
.then((result) => {
document.body.textContent = result
})
But that doesn't work in our browser!
Let's take a look at the network tab for http://thirdparty.com
:
The request was successful, but the result was not available. The reason can be found in the JavaScript console:
Aha! We are missing the Access-Control-Allow-Origin
header. But why do we need it and what is it good for?
The Same-Origin Policy
The reason why we won't get the response in JavaScript is the Same-Origin Policy. This policy was aimed at making sure that a website can't read the result from a request made to another website and is enforced by the browser.
For instance: If you are on example.org
you would not want that website to make a request to your banking website and fetch your account balance and transactions.
The Same-Origin Policy prevents exactly that.
The "origin" in this case is composed from
- the protocol (e.g.
http
) - the host (e.g.
example.com
) - the port (e.g.
8000
)
So http://example.org
and http://www.example.org
and https://example.org
are three different origins.
A note on CSRF
Note that there is a class of attacks, called Cross Site Request Forgery that is not mitigated by the Same-Origin Policy.
In a CSRF attack, the attacker makes a request to a third party page in the background, for instance by sending a POST request to your bank website. If you have a valid session with your bank, any website can make a request in the background that will be carried out unless your bank uses counter measures against CSRF.
Note that despite the Same-Origin Policy being in effect, our example request from thirdparty.com
was successfully carried out to good.com
- we just could not access the results. For CSRF we don't need the result...
For example, an API that allows sending emails by doing a POST request would send an email, if we give it the right data - an attacker doesn't care about the result, they care about the email being sent which it will regardless of the ability to see the API response.
Enabling CORS for our public API
Now we do want to allow the JavaScript on third party sites (such as thirdparty.com
) to access our API responses. To do so, we can enable the CORS header as the error said:
app.get('/public', function(req, res) {
res.set('Access-Control-Allow-Origin', '*')
res.send(...)
})
Here we are setting the Access-Control-Allow-Origin
header to *
which means: Any host is allowed to access this URL and the response in the browser:
Non-simple requests and preflights
The previous example was a so-called simple request. Simple requests are GET
or POST
requests with a few allowed headers and header values.
Now thirdparty.com
changes the implementation a little to get the JSON:
fetch('http://good.com:3000/public', {
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then((result) => {
document.body.textContent = result.message
})
But this breaks thirdparty.com again!
This time the network panel shows us the reason:
Any request that is using a method that isn't GET
or POST
or uses a content type that isn't
text/plain
application/x-www-form-urlencoded
multipart/form-data
Any other header that isn't allowed for simple requests requires a preflight request.
This mechanism is meant to allow web servers to decide if they want to allow the actual request. The browser sets the Access-Control-Request-Headers
and Access-Control-Request-Method
headers to tell the server what request to expect and the server answers with corresponding headers.
Our server doesn't answer with these headers yet, so we need to add them:
app.get('/public', function(req, res) {
res.set('Access-Control-Allow-Origin', '*')
res.set('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.set('Access-Control-Allow-Headers', 'Content-Type')
res.send(JSON.stringify({
message: 'This is public info'
}))
})
Now thirdparty.com can access the response again.
Credentials and CORS
Now let's assume that we are logged in on good.com and can access the /private
URL with the sensitive information.
With all our CORS settings, can another site, say evil.com
get this sensitive information?
Let's see:
fetch('http://good.com:3000/private')
.then(response => response.text())
.then((result) => {
let output = document.createElement('div')
output.textContent = result
document.body.appendChild(output)
})
No matter if we are logged in to good.com or not, we will see "Please login first".
The reason is that the cookie from good.com will not be sent when the request comes from another origin, in this case evil.com.
We can ask the browser to send the cookies along, even when it's a cross-origin domain:
fetch('http://good.com:3000/private', {
credentials: 'include'
})
.then(response => response.text())
.then((result) => {
let output = document.createElement('div')
output.textContent = result
document.body.appendChild(output)
})
But again this won't work in the browser. That is great news, actually.
Imagine any website could make authenticated requests - the request will be made but won't send the actual cookie and the response is inaccessible.
So, we don't want evil.com to be able to access this private data - but what if we want thirdparty.com to have access to /private
?
In this case we need to set the Access-Control-Allow-Credentials
header to true
:
app.get('/private', function(req, res) {
res.set('Access-Control-Allow-Origin', '*')
res.set('Access-Control-Allow-Credentials', 'true')
if(req.session.loggedIn === true) {
res.send('THIS IS THE SECRET')
} else {
res.send('Please login first')
}
})
But this will still not work. It's a dangerous practice to allow every authenticated cross-origin requests.
The browser does not allow us to make this mistake this easily.
When we want to allow thirdparty.com access to /private
we can specify this origin in the header:
app.get('/private', function(req, res) {
res.set('Access-Control-Allow-Origin', 'http://thirdparty.com:8000')
res.set('Access-Control-Allow-Credentials', 'true')
if(req.session.loggedIn === true) {
res.send('THIS IS THE SECRET')
} else {
res.send('Please login first')
}
})
Now http://thirdparty:8000
has access to the private data as well, while evil.com is locked out.
Allowing multiple origins
Now we have allowed one origin to do cross origin requests with authentication data. But what if we have multiple third parties?
In this case, we probably want to use a whitelist:
const ALLOWED_ORIGINS = [
'http://anotherthirdparty.com:8000',
'http://thirdparty.com:8000'
]
app.get('/private', function(req, res) {
if(ALLOWED_ORIGINS.indexOf(req.headers.origin) > -1) {
res.set('Access-Control-Allow-Credentials', 'true')
res.set('Access-Control-Allow-Origin', req.headers.origin)
} else { // allow other origins to make unauthenticated CORS requests
res.set('Access-Control-Allow-Origin', '*')
}
// let caches know that the response depends on the origin
res.set('Vary', 'Origin');
if(req.session.loggedIn === true) {
res.send('THIS IS THE SECRET')
} else {
res.send('Please login first')
}
})
Again: Do not directly send req.headers.origin
as the CORS origin header. This would allow any website access to authenticated requests to your site.
There may be exceptions to this rule, but think at least twice before implementing CORS with credentials without the whitelist.
Summary
In this article we've looked at the Same-Origin Policy and how we can use CORS to allow cross-origin requests when required.
This requires server- and client-side settings and depending on the request will cause a preflight request.
Additional care should be taken when dealing with authenticated cross-origin requests. A whitelist can help to allow multiple origins without risking to leak sensitive data (that is protected behind an authentication).
Posted on November 12, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.