CORS, or Cross-Origin Resource Sharing, is one thing that can bite a developer early on when creating a web app or backend service. It’s a check by modern browsers which provides added security for the browser user. It’s important to note that this is purely enforced by the browser, although as a whole, both web servers and web browsers play a part.
For example, CORS can help prevent a malicious case where a website executes an
HTTP request (via the Fetch API or XMLHttpRequest
) to a different
site/domain where a user may be logged in. Without CORS, that malicious website
can receive a fully authenticated response containing session data, cookies,
and/or other potentially (hopefully encrypted!) sensitive data.
Let’s take a look at how that would work in a world without CORS:
- A user just visited "https://mybank.example", one of the most popular banking websites, to complete a few transactions.
- The user, maybe on another tab, visits "http://not-suspicious.example".
- Unknown to the user,
not-suspicious.example
contains a script that sends requests to a list of endpoints from very popular banking sites. This is all done in the background. - If a response comes back containing user session data or other sensitive user data, the malicious site now has the means to impersonate the user.
Now the same example, but on a browser with CORS enabled:
- A user just visited "https://mybank.example", one of the most popular banking websites, to complete a few transactions.
- The user, maybe on another tab, visits "http://not-suspicious.example".
- Unknown to the user,
not-suspicious.example
contains a script that attempts to send requests to a list of endpoints. - Before each request, however, the user’s browser sends a request known as a "preflight request" to check if the request is possible.
- Now, let’s assume all banks are up-to-date with security. Each API server
responds and tells the browser that
not-suspicious.example
is not an origin that it trusts. - At this point, the browser considers the preflight request as failed, which also stops the real request from executing.
On the last three points of the CORS-enabled example, the browser has done its
job and prevented the attack. However, that also highlights one of its
weaknesses: the browser is key, but it can be easily disabled (i.e.
the --disable-web-security
flag for Chrome and via an extension on Firefox).
CORS should be treated as another mechanism to prevent certain attacks, and cases
where it’s disabled should be considered as well. It should be only a part of a
more comprehensive solution to secure your servers and to protect your users'
data.
On the last three points of the CORS-enabled example, the browser has done its job and prevented the attack. However, that also highlights one of its weaknesses: the browser is key, but CORS enforcement can also be disabled. This mechanism should be treated as another mechanism to prevent certain attacks and should be part of a more comprehensive solution to secure your servers and to protect your users’ data.
Now that we know what can happen without CORS, let’s step into how someone might discover this during development and dig into how to get your app ready.
Getting started
You have a project idea that will probably work well as a web app. You also want it to be modern — who wants a plain HTML site in 2021, right? That means you’ll need Javascript. You decide on a simple architecture consisting of:
- A backend server - Node.js, Python, PHP, etc.
- A Javascript/HTML/CSS frontend maybe with a framework - React, Vue.js, Angular, etc.
Perfect. Let’s whip up a quick prototype. See JSFiddle here for full HTML, CSS and JS files, and this GitHub Gist for the backend.
const API_URL = 'http://localhost:8000'
const button = document.getElementById('do-something')
function getResultEl() {
return document.getElementById('result')
}
function handleResponse(response) {
try {
response = JSON.parse(response)
} catch (e) {
// Something went wrong
console.log({ error: e })
response = null
}
const html =
response !== null && response?.length
? // Put our data in a list
response.map((item) => `<li>${item.name}</li>`).join('')
: // Or tell us it failed
'<li>Could not get response</li>'
getResultEl().innerHTML = `<ul>${html}</ul>`
}
// Make our button send a request to our backend API
button.onclick = (event) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', `${API_URL}/items`)
xhr.setRequestHeader('Content-Type', 'application/json')
// Also set any custom headers if you need, such as authentication headers
// xhr.setRequestHeader('X-My-Custom-Header', 'some-data')
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
handleResponse(xhr.response)
}
}
// Send some optional data
xhr.send()
}
Checking our work
Now that everything’s set up, let’s double-check that our endpoint works fine
when we call it from our site. What does cURL
say?
$ curl "localhost:8000/items" -v
> GET /items HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< date: Mon, 07 Jun 2021 21:16:05 GMT
< server: uvicorn
< content-length: 48
< content-type: application/json
[{"name":"One"},{"name":"Two"},{"name":"Three"}]
Looking good. Onto the browser… but it doesn’t work when you hit the button. Why? Let’s check our browser’s Developer Tools. In this case, we’ll be using Firefox below:
A few things just happened:
- In our Javascript file, we send an HTTP request to our API server at
http://localhost:8000
. - There’s not one, but two requests that were sent and they both returned error responses from our server.
- Checking our API logs we also have an error*:
- Technically, this can be resolved by explicitly allowing and handling the OPTIONS HTTP verb, but will still yield the same result.
INFO: 127.0.0.1:54748 - "OPTIONS /items HTTP/1.1" 405 Method Not Allowed
A quick look at the request headers on the first request also shows CORS headers (the ones that begin with “Access-Control-Request-”).
That sequence of events was your browser’s CORS enforcement at work.
So what is the browser doing?
Going back to the definition: CORS stands for “Cross-Origin Resource Sharing”.
As seen in the example, the browser is trying to make a request from
localhost:63342
(the frontend) to localhost:8000
(the backend). These two
hosts are considered different "origins" (see MDN’s full definition for "origin").
Once a cross-origin request is detected, the browser sends a preflight request
before each cross-origin HTTP request to make sure the actual request can be
handled properly. This is why the first request in our example was an
OPTIONS
request that we never called for in the Javascript code.
On Chrome’s DevTools, you can also see this happen more clearly as it combines the request and the preflight request:
Getting your backend ready
The good news: depending on how your backend is developed, handling CORS can be as simple as installing a package and/or changing a few configs.
As examples, in the Javascript world, koa and express both have middleware packages that have quick setup:
In the example here, I’ll be using a snippet from a FastAPI app as it demonstrates the headers more succinctly:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
# We add the middleware here
CORSMiddleware,
# These are the options we give the middleware and they map easily to their
# associated CORS headers
allow_origins=['http://localhost:63342, ‘http://localhost’],
allow_methods=['GET', 'POST']
)
Keep in mind that the same domain with a different port requires a new entry.
In the snippet above under allow_origins
, we’ve added localhost
and
localhost:63342
since those are the URLs where we might call our backend API
for data.
Also under allow_methods
, you can see that we can finely tune our backend to
only accept certain methods. You could, for example, lock down this API service
further by only accepting GET
requests, if it’s a simple service that provides
data without requiring user input -- like an API that provides business hours
for a specified store.
With that ready, let’s try making the request again. Below is the preflight
request (OPTIONS
):
Perfect. It’s now allowing our origin, and shows us the allowed methods. Also,
it shows which headers are allowed in the requests. The allowed headers listed
are typical defaults, but if you need to use other headers for your use-case,
you can allow all of them completely with access-control-allow-headers: *
or
explicitly list all the headers you want to support.
For a more detailed listing of CORS-related headers, take a look at Mozilla’s documentation
Hopefully this brings clarity and demystifies any questions that you may have had with CORS, its effects, and getting a simple app to support it. Having a sound CORS policy should only be considered as a small cog in the complex world of web security. Since this only protects one specific attack vector, one should stay vigilant to keep their servers and users’ data secure.
If you’re developing something exciting with PDFs and/or paperwork, we’d love to hear from you. Let us know at developers@useanvil.com.