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.
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:
- 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.
- 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:
- Sending "simple" requests with a whitelisted HTTP verb, headers, and content-type is allowed, but accessing the response is not.
- 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 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.