How to: CORS — Part 2: Cross-Origin Resource Sharing

Marcin Sodkiewicz
10 min readJan 23, 2023

--

I often find that experienced developers do not understand CORS. These articles are for them — devs who have been in IT for a long time and at this point are afraid to ask how CORS works in detail. It should also be a good reading for anyone who wants to understand it.

If you started to read this series at part 2, I encourage to go and read about SOP first under https://medium.com/@sodkiewiczm/deal-with-cors-using-serverless-part-1-intro-952a5c6b916.

TLDR: I’m going to try to make you understand how CORS works so this won’t be a really quick article and I recommend reading all parts. If you need a reminder just take a look at scenarios GIFs, if you want to learn about CORS quickly, check out https://www.youtube.com/watch?v=4KHiSt0oLJ0 as a starter and if it’s not clear enough, you can always come back here.

In the previous part I explained what kind of requests are possible cross-origin. The only problem was that we couldn’t get a response even with the best intentions. Now, as promised, we will try to solve this problem by introducing CORS support.

Cross-Origin Resource Policy

CORS is a mechanism that allows us to have a security policy that isn’t as strict as same-origin policy, but still improves our security posture. Now let’s talk about how we can finally make it possible to use cross-domain resources.

In simple terms: CORS defines a browser mechanism that allows the server to decide whether it trusts its requests or not.

Spoiler alert: thanks to this, we avoid the problem of sending requests that shouldn’t be allowed (like sending cookies in part 1).

Below is the difference in the logs when the request comes from sodadev.github.io and the origin of our test API. As you can see the main difference is based on the Origin header in the request and this is what the CORS mechanism is based on.

How does it work?

What is involved? Please note that Server is a new component in this flow. It was not part of the Same-Origin Policy, where server had nothing to say about the request it received. Only the browser made any decisions. Now let’s take a look at the happy path flow.

Preflight request

First of all, why was it introduced? It requires an extra call, so it generates more traffic. You might think that it sucks, but it was introduced to give a server a choice about whether to allow that call or not, and it’s a really critical security mechanism.

Do you remember an example with CSRF that we looked at in part 1? Exactly to protect against such scenarios! Right now such request won’t be sent. Even if we have a really bad implementation or a legacy system, the user won’t be vulnerable to attack (because CORS is backwards compatible — we’ll come back later to that later).

Imagine a scenario where there’s no preflight request and because of the attack, someone could maliciously invoke a call to another origin with some request on your behalf. With only an SOP-like mechanism in place, the attacker wouldn’t be able to read the response and that’s great, but… Imagine what would happen if the attacker sent a request to a social media platform with an offensive post on your behalf, for example. They could also try to delete resources such as messages or friends from your social account, or perform some other mutating action. This is why preflighting requests is so important.

So back to business, because I think we agree that it’s really important. What are the details? It’s the OPTIONS request to the server, which is in another origin, to check if the call is allowed or not. What does it contain? Information about:

  • Origin — where request comes from
  • Access-Control-Request-Method — what HTTP method will be used
  • Access-Control-Request-Headers — list of all non-standard headers

Based on these informations, the server must decide whether request will be allowed or not. The server responds with information about what is allowed using Access-Control-Allow- headers family to specify allowed origin, methods, headers and credentials. But don’t focus on that, we’ll come to that later.

I hope it’s obvious, but to make it clear: This is ONLY browser mechanism. It won’t be taken into account using any http client from CLI / Backend

Simple CORS request

It’s not using preflight request. Usually it is described in details, but we already have covered that one under SOP. It’s just more or less just that. Any call that could be done without using XHR.

Which calls are recognised as simple CORS requests? Once that have

Method: one of GET, POST, HEAD
Headers: one of Accept, Accept-Language, Content-Language
Content-Type: one of application/x-www-form-urlencoded, multipart/form-data, text/plain

and you can take a look for some extra conditions

Quick one on retrieving data without using CORS

With a dummy implementation like ours, we can load the same data no matter what we would pass in the Content-Type request header. So let’s try to load data without triggering CORS. Let’s start with such code:

exports.handler = async (event) => {
console.info(event)
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(event)
}
}

It will return 200 OK, but browser won’t be able to read response without the proper headers in the response. Preflight request was not sent because we didn’t use any forbidden header.

Just like in this scenario:

So now let’s do something with our API to make it work. Let’s allow any origin to use our API! I have modified lambda function code to:

exports.handler = async (event) => {
console.info(event)
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify(event)
}
}

After trying making that call again, it works!

That’s, great. So we can see the data, but we are using text/plain, so what would happen if we changed Content-Type to application/json?

You should really remember that example. This is why proper handling of Content-Type on server side is so important.

Complex CORS request

Since Content-Type: application/json is not allowed in a simple CORS request it should use preflight. Now let’s undo the changes in the lambda and do not return any CORS headers and then let’s call it in the browser to get the infamous CORS error.

const options = {
method: 'GET',
headers: {'Content-Type': 'application/json'}
};

fetch('https://hm5avt5kpq4bne66qdgodqugyq0upqri.lambda-url.eu-west-1.on.aws/data.json', options)
.then(response => response.json())
.then(response => console.log(response))
.catch(err => console.error(err));

Which results in:

“Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.” Which means that we didn’t return mandatory Access-Control-Allow-Origin header.

This flow looks similar to this:

But since our lambda doesn’t have any security built-in the question comes to…

Do I have to use it?

Well, yes. CORS is an opt-in mechanism. This means that the server doesn’t have to be aware of any security mechanisms that need to be implemented. If the server does not return certain headers, it means that it is not allowed to use them. That way even legacy solutions got this mechanism.

So let’s do change in lambda again and let’s return that Access-Control-Allow-Origin: '*' header to browser. Now we are getting error:

“Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response” so Content-Type header is missing in our response Access-Control-Allow-Headers header. This flow looks similar to this one:

For now let’s add Access-Control-Allow-Headers with wildcard, just like we did with with Access-Control-Allow-Origin.

exports.handler = async (event) => {
console.info(event)

return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*'
},
body: JSON.stringify(event)
}
}

Now after making a call we can see that our API is returning the data!

As you can see in the Network tab screen below, both the preflight and the GET request are executed and return status 200.

The question that should go through your mind is: But does it make sense? Which leads us to…

Can I use ‘’*” in Access-Control-Allow-Origin?

Combination of Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true together should be avoided. It will even be blocked by some browsers. Just like in Chrome, where we expect an error: “Response to preflight request doesn’t pass access control check: The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ‘*’ when the request’s credentials mode is ‘include’”.

Lists of origins are not supported in practice, and if we want to allow multiple origins to consume our resources, some might think that there is no choice — they have to use *. But first of all, you’ll be vulnerable to CSRF attacks and won’t be able to send credentials.

How can we support multiple origins then?

The best way is to have a domain allow list and do it based on the allow list. You have to be careful with your implementation. Let’s look at some scenarios.

ALWAYS USE ALLOW LISTS INSTEAD OF CUSTOM IMPLEMENTATIONS!

Regex strategy abuse

Based on OWASP using regex you could allow access to all subdomains of example.com which you are owner of course, but attacker could use http://example.com.attacker.com as his Origin.

Echo strategy

Do not return an incoming Origin header value as Access-Control-Allow-Origin. It’s better to return *. Why? In case of * browser won’t allow you to send credentials, but in the case of returning back Origin from request, the browser will send credentials with the request. Which is obviously way worse.

So with our dummy implementation we could do something like:

const allowedOrigins = {
'https://sodadev.github.io': true
}

exports.handler = async (event) => {
console.info(event)

const origin = event.headers['origin']
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET,PUT'
}

if (allowedOrigins[origin]) {
headers['Access-Control-Allow-Origin'] = origin
}
return {
statusCode: 200,
headers: headers,
body: JSON.stringify(event)
}

};

and have our response with “https://sodadev.github.io” in Access-Control-Allow-Origin header in the response like on screen below:

Buggy implementaions and null Origin

It’s also important to watch out for your implementation and do not return null as Origin. Like in this Facebook vulnerability which might be a common implementation bug. Thst malformed implementation could be implemented as in the following snippet:

// This is example of BAD implementation
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': allowedOrigins[origin] ? origin : null,
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Credentials': 'true'
},
body: JSON.stringify(event)
}

At this point you might ask yourself a question…

These OPTIONS requests have to be sent with every request?
Yes, they do. You can limit amount of those requests using caching through Access-Control-Max-Age header though.

Malformed implementation

At this point you should have a clear understanding of how CORS works, but first ask yourself a question.

What happens if all the Access-Control… headers are only present in the preflight request, but not in the response?

Let’s tale our temporary implementation below. Do you know the answer?

const allowedOrigins = {
'https://sodadev.github.io': true
}

exports.handler = async (event) => {
console.info(event)

if (event.requestContext.http.method !== 'OPTIONS') {
return {
statusCode: 200,
body: JSON.stringify(event)
}
}

const origin = event.headers['origin']
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET,PUT,POST'
}

if (allowedOrigins[origin]) {
headers['Access-Control-Allow-Origin'] = origin
}
return {
statusCode: 200,
headers: headers,
body: JSON.stringify({})
}

};

Support questions:
- What will be the status of request after preflight?
- What will be the response of request after preflight?
- Would browser send cookies to backend?

Answer is: in this case, since preflight works fine, the request will be sent to the backend with auth data, BUT the response won’t be available to the browser.

All implementations presented here are demo only to show how CORS works based on simplest possible implementation. Any framework you use supports CORS for 100%, so use recommended solution that follows best practices (hopefully).

Summary

I hope that at this point you understand whole requests flow when it comes to Cross-Origin Resource Sharing and do understand why we have preflight requests in our browsers.

I know that sometimes during development CORS might be annoying, but you shouldn’t forget about its impact on security. This is what we will look into in the last part of this series.

Thanks to CORS you can think of attacker as a digital vampire that can’t get into your system without asking you for permission.

--

--