CORS (Cross-Origin Resource Sharing): A Complete Guide

CORS (Cross-Origin Resource Sharing): A Complete Guide

Never be frustrated with CORS again. Learn what cross-origin resource sharing is, why it exists, and how to embrace it.

Never be frustrated with CORS again. Learn what cross-origin resource sharing is, why it exists, and how to embrace it.

You can read the original article here: CORS on AppSec Monkey

What is CORS?

CORS, or Cross-Origin Resource Sharing is an opt-in browser feature that websites can use to relax the same-origin policy in a controlled way.

Browsers facilitate CORS via the Access-Control-Allow-* headers, which we'll get to soon.

I don't want you to be frustrated with CORS, so let's cover just a little bit of theory first. Specifically, let's take a look at the same-origin policy.

What is the Same Origin Policy?

I've written about this at length in here, but to give you the TL;DR, the same-origin policy is a set of design principles that govern how web browser features are implemented.

Its purpose is to isolate browser windows (and tabs) from each other.

For example, when you go to example.com, the website will not be able to read your emails from gmail.com (which you may have open in another tab). This is due to the workings of the same-origin policy.

What is an Origin?

The definition of an origin is simple. Two websites are of the same origin if their scheme (http://, https://, etc.), host (e.g., appsecmonkey.com), and port (e.g., 443) are the same. You can find the definition in RFC6545 - The Web Origin Concept.

figure-1.jpg

Implicit ports

If the port is not explicitly specified, it's implicitly 80 for http and 443 for https.

Examples

Browsers consider these URLs to be of the same origin:

  • https://www.appsecmonkey.com/
  • https://www.appsecmonkey.com/blog/same-origin-policy/
  • https://www.appsecmonkey.com:443/blog/same-origin-policy/

And these are all of different origins:

  • http://www.appsecmonkey.com/
  • https://appsecmonkey.org/
  • https://www.appsecmonkey.com:8080/

What is allowed by the same-origin policy, and what is not?

In general, writing and embedding are allowed, and reading is denied. How exactly this applies depends on the browser feature, but here are a few examples that concern CORS in particular. We can divide the examples into two categories:

  1. Sending the HTTP request is allowed, but accessing the response is not. This is the case for simple requests with whitelisted HTTP verb, headers, and content-type.
  2. Even sending the request is not allowed. This is the case with preflighted requests with non-whitelisted HTTP verb, headers, or content-type.

✅ Allowed: Sending credentialed cross-origin GET, HEAD, and POST requests with XHR

The following will work. You will get an error, but the request will be sent. You can verify with your browser's developer tools, or better yet, set up a proxy tool such as OWASP ZAP between your browser and the webserver to see what's going on.

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('GET', 'http://b.local/');
xhr.send()

Developer tools reveal that the browser indeed sent the following HTTP request to the server.

GET / HTTP/1.1
Host: b.local
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:86.0) Gecko/20100101 Firefox/86.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Origin: http://a.local
Connection: keep-alive
Referer: http://a.local/
Cookie: SESSIONID=s3cr3t
Pragma: no-cache
Cache-Control: no-cache

❌ Not allowed: Inspect the XHR response

However, you will not be able to read the response that you get. This is the same-origin policy in action. Writing (sending the XHR request) is allowed, but reading the response is not.

❌ Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://b.local/. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).

✅ Allowed: Sending credentialed cross-origin GET, HEAD, and POST requests with fetch

Using fetch will work just the same. We can use JavaScript to submit an URL-encoded form to the webserver.

fetch('http://b.local/', {method: 'POST', credentials: 'include', body: "foo=bar", headers: {
    'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},});

And the following HTTP request is sent:

POST / HTTP/1.0
Host: b.local
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:86.0) Gecko/20100101 Firefox/86.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://a.local/
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Origin: http://a.local
Content-Length: 7
Connection: keep-alive
Cookie: SESSIONID=s3cr3t
Pragma: no-cache
Cache-Control: no-cache

❌ Not allowed: Inspect the fetch response

It's the same with fetch. Sending the credentialed, cross-origin POST request was permitted, but accessing the response was denied.

❌ Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://b.local/. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).

We'll get to what the Access-Control-Allow-Origin thing is in a minute, but let's look at a couple more scenarios first.

❌ Not allowed: Sending PUT, PATCH, DELETE, etc. requests

Only specific HTTP verbs are allowed by default (GET, POST, HEAD, and OPTIONS).

fetch('http://b.local/', {method: 'PUT', credentials: 'include'});
❌ Access to fetch at 'http://b.local/' from origin 'http://a.local' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Note that this time the browser didn't send the request at all. Also, note that the error is now different. It's talking about a preflight request. We'll get to that soon.

❌ Not allowed: Sending arbitrary headers

Trying to send a request with arbitrary headers is not allowed by the same-origin policy.

fetch('http://b.local/', {
    method: 'POST', credentials: 'include', headers: {
        'Foo': 'Bar'
    },
});
❌ Access to fetch at 'http://b.local/' from origin 'http://a.local' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Only the CORS-safelisted headers are allowed by default. And they are:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type

❌ Not allowed: Sending JSON requests

While the Content-Type header is safelisted, it is so with restrictions. Specifically, only the following values are acceptable:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain
fetch('http://b.local/', {
    method: 'POST', credentials: 'include', headers: {
        'Content-Type': 'application/json'
    },
});
❌ Access to fetch at 'http://b.local/' from origin 'http://a.local' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Again with the preflight.

Alright, now that you understand the restrictions that same-origin policy places upon you, I'll tell you what CORS is and how it helps you get around those limitations in a controlled way.

Cross-Origin Resource Sharing (CORS)

CORS can lift the above restrictions. It's not a browser security mechanism like the same-origin policy. CORS is a browser insecurity-mechanism, so read carefully and use it with consideration.

Browsers implement CORS as a set of four HTTP response headers, which we'll get to right now.

Access-Control-Allow-Origin

The first header is Access-Control-Allow-Origin. Developers can use it to grant cross-origin requests the read-permission to a website's resources (which is denied by the same-origin policy by default).

Possible values are:

  • a specific origin such as https://www.appsecmonkey.com
  • a wildcard, allowing any domain

For example:

Access-Control-Allow-Origin: https://wwww.appsecmonkey.com

Or:

Access-Control-Allow-Origin: *

It's either-or. Something like this would not work:

Access-Control-Allow-Origin: *.appsecmonkey.com

Also, specifying multiple origins is not allowed, so the following would not work either.

Access-Control-Allow-Origin: https://foo.appsecmonkey.com, https://bar.appsecmonkey.com/

CORS wildcard restrictions

The wildcard will not work in combination with Access-Control-Allow-Credentials: true, which you'll learn about shortly. Just remember this limitation.

How to specify multiple CORS origins?

The spec doesn't support multiple origins. However, in practice, it has been solved by generating the Access-Control-Allow-Origin header dynamically in your code. This workaround is facilitated by the Origin request header, which browsers send in all POST and CORS requests.

Note that there is a security hazard here. It's best to use a well-established CORS library for your development framework of choice instead of implementing a homemade solution.

At any rate, you should have a strict whitelist of possible origins. Do not implement logic that, e.g., checks if the request origin contains a specific string or starts with one.

Access-Control-Allow-Credentials

By default, CORS doesn't allow credentialed requests (that include the browser user's cookies). After all, credentialed CORS requests effectively give the websites to whom the privilege is granted full read and write control of the browser user's data in the application.

If you still want to enable it, you can use the Access-Control-Allow-Credentials header like so:

Access-Control-Allow-Credentials: true

Just note the limitation mentioned above: this does not work in conjunction with Access-Control-Allow-Origin: *.

Important caveat about SameSite cookies

Browsers are starting to adopt SameSite cookies in Lax mode as the default. I've written about SameSite cookies at length here.

What this means is that cookies are by default protected from cross-site interactions. If you are banging your head against the wall trying to figure out why the browser doesn't send your cookie even when you have all the proper headers in place, it might be SameSite cookies in action.

Access-Control-Allow-Headers

If you want to send custom headers or lift the restriction on the Content-Type header to, e.g., send JSON requests, you can use the Access-Control-Allow-Headers to do so.

Access-Control-Allow-Headers: content-type

Access-Control-Allow-Methods

Finally, suppose you want to enable other HTTP verbs than GET, POST, HEAD, and OPTIONS. In that case, you have to use the Access-Control-Allow-Methods header.

Access-Control-Allow-Methods: GET, POST, HEAD, PUT, PATCH, DELETE

In fact, even if you only want to allow, e.g., POST requests, you are still required to return Access-Control-Allow-Methods if there are any other factors that cause your request to be preflighted, which we'll talk about next.

Preflight

Simple requests with the whitelisted HTTP verbs, headers, and content-type are always sent. Still, the website is forbidden access to the response data if the response doesn't contain the appropriate Access-Control-Allow-Origin header.

But how does the browser know whether it is allowed to send a PUT request or not? If the answer to the question "can I send a PUT request" is in response to the PUT request, doesn't this create a chicken and egg problem? That's a great question, and the answer is simple: we send two requests.

The browser first sends an OPTIONS request with the Origin header, and then looks at the response headers for that request. If PUT is allowed (in Access-Control-Allow-Methods), only then the preflight succeeds, and the browser sends the desired PUT request.

Of course there are other reasons why the preflight might fail. For example, you might be trying to send a request with credentials included, but the webserver doesn't return Access-Control-Allow-Credentials: true in the preflight HTTP response.

This first OPTIONS request is aptly named the preflight request.

CORS in action

Let's revisit one of the tests we made earlier, but this time, b.local returns the following HTTP response headers:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: GET, HEAD, POST, PUT
Access-Control-Allow-Origin: http://a.local

✅ Allowed: Sending credentialed cross-origin GET, HEAD, POST, and PUT requests with fetch and reading the response

Now we have complete control of the cross-origin page.

fetch('http://b.local', {method: 'PUT', credentials: 'include', headers: {
        'Content-Type': 'application/json'
    }}).then(function (response) {
    return response.text();
}).then(function (html) {
    // This is the HTML from our response as a text string
    console.log(html);
});

<!doctype html>
<html lang=en>

<head>
    <meta charset=utf-8>
    <title>b.local</title>
    ...

☠ Security Impact: If you specify CORS headers like this, you are giving the allowed origins complete control over your website, including any authenticated user data and functionality. The same-origin policy is there to protect you, so think carefully before opting out of it.

Using CORS

If your requirements are simple, you can just add the static headers to your application/web server configuration.

However, if you have to deal with multiple origins, it's best to use a CORS library for your development framework. For example, this is how you configure CORS in flask:

CORS(
    app,
    origins=['http://a.local', 'http://c.local'],
    allow_headers=['content-type'],
    supports_credentials=False,
    methods=['PUT', 'PATCH', 'DELETE']
)

Exposing headers

By default, only the CORS-safelisted response headers are exposed to to JavaScript code in CORS requests. So if your webserver returns the header Foo: Bar, even CORS requests won't be able to access it.

If you want browsers to access this header, you can do so via the Access-Control-Expose-Headers like so:

Access-Control-Expose-Headers: Foo

Conclusion

Browsers consider two websites to be of the same origin if they have the same scheme, host, and port.

The same-origin policy places several restrictions over cross-origin interactions. Most notably for CORS:

  1. Sending "simple" requests with a whitelisted HTTP verb, headers, and content-type is allowed, but accessing the response is not.
  2. Sending "preflighted" requests with non-whitelisted HTTP verb, headers and content-type are not allowed at all.

CORS, or Cross-Origin Resource Sharing, is a browser insecurity-mechanism for your web application to opt-out of some of these restrictions in a controlled way.

However, CORS should be used with consideration and implemented using a well-established CORS library for your development framework of choice.

Get the web security checklist spreadsheet!

Subscribe ☝️ Subscribe to AppSec Monkey's email list, get our best content delivered straight to your inbox, and get our 2021 Web Application Security Checklist Spreadsheet for FREE as a welcome gift!

Don't stop here

If you like this article, check out the other application security guides we have on AppSec Monkey as well.

Thanks for reading.