loading...

Understanding CORS

g33konaut profile image Martin Splitt ・7 min read

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 network request was successful

The request was successful, but the result was not available. The reason can be found in the JavaScript console:

The console shows that a missing CORS header causes the problem

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:

The response is available once we set the CORS header

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:

The request has been preflighted with an OPTIONS request

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).

Discussion

pic
Editor guide
Collapse
malvoz profile image
Robert Linder

In the "Allowing multiple origins" example, Vary: Origin should be set in the response to avoid incorrect content being served (if content may differ depending on the requesting origin). See fetch.spec.whatwg.org/#cors-protoc...

Collapse
g33konaut profile image
Martin Splitt Author

Good point, thanks for your input! Added it to the example in that section :)

Collapse
iamandrewluca profile image
Andrew Luca

Do we need CSRF protection if CORS is disabled (now allowed from other domains) ?
For me it seems logic that is no need for CSRF protection if CORS is disabled, but couldn't find any exact answer.

Collapse
g33konaut profile image
Martin Splitt Author

Note that without CORS headers the request is still happening, you just don't have access to the response. Unless you have some server-side mechanism to detect requests from other origins, you could still run the risk of CSRF. I'm with @theincorrigible1 that you should protect against CSRF on any inputs that can change state.

Here's a potential example:

  • Say your router is at 192.168.0.1 and there's a form to change the admin password with a POST request, e.g. POST to 192.168.0.1/settings with a body like "password=abc123".
  • If some website now makes such a request, it won't see the response from the router (that's what CORS prevents), but the request would happen and possibly change your router password..
Collapse
iamandrewluca profile image
Andrew Luca

Now I get it.
But can the attacker make a simple request, and get a CSRF token,
then make second request with that token included?

Collapse
mburszley profile image
Maximilian Burszley

You should protect against CSRF on any inputs that can change state imo.

Collapse
iamandrewluca profile image
Andrew Luca
  • CSRF is Cross-site request forgery
  • CORS is Cross-origin resource sharing

If no one from another origin is able to make requests to your site (CORS disabled),
then CSRF is redundant imo.

Thread Thread
mburszley profile image
Maximilian Burszley

But that's not what CORS does. Re-read the warning in the article.

Collapse
richardeschloss profile image
Richard Schloss

Thanks for writing this. Bookmarked.

Collapse
tarasnovak profile image
Info Comment marked as low quality/non-constructive by the community. View code of conduct
dataPixy 🧚‍♂️

I'll just leave this here :)

Collapse
kbluescode profile image
Kevin Blues

I'm confused. It sounds like you're insulting the article (looks like you're calling it "weak" but typo'd), but offering no real alternatives to anything posted here.

I followed your link but it just seems to go to a random forum post where you discuss cors-anywhere, not educating anyone on CORS itself.

If you have some constructive criticism or something to add value to this post, feel free. This just reads like you're being aggressive and not helpful.

I for one found the OP educational and helped me understand things better.

Collapse
tarasnovak profile image
dataPixy 🧚‍♂️

my follow up is here:

You should really just follow the links ...

I hope you'll find it handy and educational too ;)

Thread Thread
kbluescode profile image
Kevin Blues

You just link to a basic example that uses CORS at some point, then a link to the MDN docs on CORS. If by educational, you mean I get to read that, then I guess?

I far prefer OP's article here, since it walks through why the various CORS headers are needed, and the results of each stage.

No need to be a jerk about it.

Thread Thread
tarasnovak profile image
Info Comment marked as low quality/non-constructive by the community. View code of conduct
dataPixy 🧚‍♂️

haha! how about your write your own post on #CORS rather than keep on piling up here ...

I provided context for CORS in action on one of the frequently used online sites for devs & data scientists, with a brief example any dev can try on Observable HQ || glitch.

If you'd rather read long CORS specs recaps & clone a github repo with that simple nodeJS index.js example, I'll leave it up to you.

Cheers!

Thread Thread
kbluescode profile image
Kevin Blues

I don't get why you think you're being "piled up on". You started by insulting the article on Twitter, then linking that tweet here, effectively insulting it again.

You aren't the victim, you're being called out for being a jerk.

My advice is to look closely at what you did, apologize and do better going forward.

Thread Thread
tarasnovak profile image
Info Comment marked as low quality/non-constructive by the community. View code of conduct
dataPixy 🧚‍♂️

Kevin, go write your ideal post on CORS. You could use 1 :)

Collapse
brittanmcg profile image
Brittan McGinnis

I don't think that there is any need to be rude. I thought that it was a pretty helpful article. You could add supplementary content in the comments without being a jerk 😒

Collapse
tarasnovak profile image