DEV Community 👩‍💻👨‍💻

Martin Splitt
Martin Splitt

Posted on • Updated on

Understanding CORS

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'
  }));
})
Enter fullscreen mode Exit fullscreen mode

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.')
  }
})
Enter fullscreen mode Exit fullscreen mode

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'
    }))
  }
})
Enter fullscreen mode Exit fullscreen mode

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
  })
Enter fullscreen mode Exit fullscreen mode

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(...)
})
Enter fullscreen mode Exit fullscreen mode

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
  })
Enter fullscreen mode Exit fullscreen mode

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'
  }))
})
Enter fullscreen mode Exit fullscreen mode

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)
  })
Enter fullscreen mode Exit fullscreen mode

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)
  })
Enter fullscreen mode Exit fullscreen mode

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')
  }
})
Enter fullscreen mode Exit fullscreen mode

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')
  }
})
Enter fullscreen mode Exit fullscreen mode

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')
  }
})
Enter fullscreen mode Exit fullscreen mode

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

Top comments (18)

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

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

Collapse
 
iamandrewluca profile image
🥑 Andrew Luca • Edited on

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

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 • Edited on

Thanks for writing this. Bookmarked.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.

Create an Account!

👀 Just want to lurk?

That's fine, you can still create an account and turn on features like 🌚 dark mode.