On a recent client project, my team was loading a third-party script into a front-end application using a script tag, but it was failing to load due to a CORS error. Hold up, I thought, this problem never normally happens for script tags, does the same-origin policy even apply here? And if it does, surely the CDN hosting the script should be configured to set headers to allow access from any origin? I quickly spiraled into self-doubt regarding my understanding of CORS. Take a look at this meme on the two states of every programmer, and you'll know what I'm talking about. 😖
It turned out that my understanding was correct, and we were just tripping over a very specific detail (revealed at the end of this post if you get that far). But why did I have so little confidence that my understanding was correct? I realized it was because I was hazy on the kind of attack that cross-origin restrictions are designed to prevent. Understand the form of attack, and the fundamentals are clear; and if the fundamentals are clear, you will know what questions to ask when you run into a problem.
If, like me, you've ever been confused by CORS and the same-origin policy, this post is for you. It will give an example, with working code, to explain such an attack, and help you get quickly to the right questions next time you run into a problem.
Terminology
One common source of confusion with this topic is the meaning of the terms we use. Let's clear up any confusion with straightforward definitions of three key terms.
Origin: The origin of content served on the internet is defined by its domain, scheme (such as http), and port. If these are the same, the origin is the same. Generally speaking, we encounter differences primarily in the domain; for example 8thlight.com
and google.com
are different domains. You are also likely to encounter the significance of the port during local development. If your front end is served from localhost:3000
, and it loads data from an API on localhost:8080
, the browser will treat these as different origins.
CORS - Cross-Origin Resource Sharing: Devs quite often identify CORS as the problem when they run into trouble with cross-origin requests. In fact, CORS is the solution that allows your front-end application to use XMLHttpRequest and the Fetch API to make requests to servers with a different origin. Without CORS, the same-origin policy would prevent any such requests.
Same-origin Policy: This policy is what may cause requests from your front-end application to a different origin to fail with a CORS error. It is a browser security policy that places restrictions on how a resource from one origin can interact with a resource from another origin — for example, whether a script loaded from hacker.com
can access resources it requests from mybank.com
.
Example Attack
To really understand the same-origin policy and CORS, you are going to use a code sample to simulate different attacks and interactions. First, you will disable cross-origin restrictions in the browser, and find out how easy it would be for hackers to steal our data without this restriction. Next, you will re-enable cross-origin restrictions, and see how this prevents attacks. Finally, you will use CORS to allow resources to be shared across origins. But before we get to all this, we need to understand the code that we will use.
The Code
The code below defines two web servers. One is an innocent application, which stores some of your private data; call it appGood
. The other is a bad app, and is trying to steal your data; let's call it appEvil
. In real life, imagine that appGood
could be your Bank, and appEvil
could be a seemingly innocent website, which you don't realize is up to no good when you visit it. appEvil
will load a script which will make a request to the appGood
server in an attempt to steal your data. You will discover how the success or failure of this depends upon the cross-origin policy.
The code sample below is extremely simplified to aid in understanding; it is not intended as an example of how to implement any production code. Note that although both are available on localhost, the browser will treat them as different origins because of the different port.
Look through the comments in the code below to understand how it works. To run it you will need to:
-
Install node and npm on your system; instructions here.
-
Create a new directory, and within it a file
index.js
:mkdir cors-example cd cors-example touch index.js
-
Copy the sample below and paste it into the
index.js
file you just created. -
Within that directory, install npm packages:
npm init npm i
-
You are now ready to start the two servers, run the following command:
node index.js
Code
const express = require("express");
const cookieParser = require("cookie-parser");
/////////////////
// GOOD APP //
///////////////
// create an express server app and define port
const appGood = express();
const portGood = 8080;
// configure the app with middleware
// this is some plumbing which you can choose to ignore
appGood.use(
express.urlencoded({
extended: true,
})
);
appGood.use(express.json());
appGood.use(cookieParser());
/**
* "/" endpoint handler
* returns html for a login form
*/
appGood.get("/", (req, res) => {
res.send(`
<p>Hello 😀</p>
<form action="login" method="post">
<label for="userPassword">Password: </label>
<input id="userPassword" type="password" name="password">
</form>
`);
});
// store a shared sessionId as a crude form of session management
let sessionId;
/**
* "/login" endpoint handler
* checks if the password is correct
* if so, creates a new session id for the user and stores it
* in sessionId above
*/
appGood.post("/login", (req, res) => {
if (req.body.password === "password") {
sessionId = `${Math.ceil(Math.random() * 1000000)}`;
res.cookie("session", sessionId);
res.redirect("/secrets");
return;
}
res.send("Wrong password, please try 'password'");
});
/**
* "/secrets" endpoint handler
* checks if the session cookie is correct
* if so, returns private data
*/
appGood.get("/secrets", (req, res) => {
console.log("==== session cookie ====\n", req.cookies.session);
if (req.cookies.session && req.cookies.session === sessionId) {
res.send("This is your super private data!");
return;
}
res.send("Permission denied");
});
// Start appGood server
appGood.listen(portGood, () => {
console.log(`Good app: localhost:${portGood}`);
});
/////////////////
// EVIL APP //
///////////////
// create another express server app and define another port
const appEvil = express();
const portEvil = 8666;
/**
* "/" endpoint handler
* Responds with a script which attempts to retrieve your private data
* by making a request to appGood; results from this request are displayed to
* the user
*/
appEvil.get("/", (req, res) => {
res.send(`
<p>Hello 😈</p>
<script>
function reqListener () {
alert(this.responseText);
}
var req = new XMLHttpRequest();
req.withCredentials = true;
req.addEventListener("load", reqListener);
req.open("GET", "http://localhost:8080/secrets");
req.send();
</script>`);
});
// Start appEvil server
appEvil.listen(portEvil, () => {
console.log(`Evil app: localhost:${portEvil}`);
});
Life Without the Cross-Origin Policy: Danger Everywhere
To experience the dangers of a world without a cross-origin policy, we first need to disable cross-origin restrictions in our browser. Instructions for Chrome can be found here, and for Safari here; you should be able to find instructions for other browsers where possible with a little googling.
Run the servers with node index.js
, and in your browser navigate to the good app at http://localhost:8080. Enter the password "password" to view your private data. You now have a session cookie stored in your browser; the presence of this cookie allows you to view your private data. Now navigate to the evil app at http://localhost:8666, and see that it is also able to access your private data! But how is it possible that one server can make requests to another and retrieve data that should be private?
The answer is that when a request is made from the browser using XMLHttpRequest or Fetch APIs and the requests credential mode is include
, the browser will add any cookies it has for that domain to the request; it doesn't matter if the request originated from a script loaded from a different site. appEvil
takes advantage of this fact to retrieve your private data.
Cross-Origin Policy to the Rescue!
Re-enable cross-origin restrictions by reversing whichever instructions you followed earlier for disabling them.
Again, run the servers, login to appGood
at http://localhost:8080, and then navigate to appEvil
at http://localhost:8666. This time, appEvil is not able to retrieve your private data. Open up the console and you should see an error like this, which informs you that the access to appGood
was blocked because it has a different origin and CORS has not been enabled:
Now check the server logs. You should see that the request made by appEvil
to appGood
was successful; there is a log of the session id. So how is it that appEvil
was blocked? The answer is that it is only when the response is returned to the browser that the browser decides whether to allow appEvil
to access that response. The browser makes this decision based upon whether certain headers are present on the response, as we shall see in the next section.
Allowing Cross-Origin Requests
It's possible for the server, appGood
in our case, to tell the browser that requests from other origins are allowed. It does this by adding headers to the response. In our case, if the browser sees that the response to appEvil
request to appGood
includes these headers, it will allow appEvil
to access the response data.
Let's imagine that you want to allow this to happen; for example, you might own two different origins, and want them to be able to load resources from each other. To make this possible, you are going to add these headers:
-
Access-Control-Allow-Origin
: This header tells the browser that another origin, or any origin, is allowed to access this resource. Read more on MDN. -
Access-Control-Allow-Credentials
: This header tells the browser that cross-origin requests including cookies are allowed. Read more on MDN.
Modify the appGood
server /secrets
endpoint to respond with these headers as below. Once you've made this change, restart the servers.
appGood.get("/secrets", (req, res) => {
console.log("==== session cookie ====\n", req.cookies.session);
if (req.cookies.session && req.cookies.session === sessionId) {
res.send("This is your super private data!");
return;
}
// NEW HEADERS
res.setHeader("Access-Control-Allow-Origin", "http://localhost:8666");
res.setHeader("Access-Control-Allow-Credentials", true);
res.send("Permission denied");
});
In your browser, log in to appGood
at http://localhost:8080, and then navigate to appEvil
at http://localhost:8666. Once again, appEvil
is able to display your private data. But this time it is only possible because appGood
has explicitly told the browser that this is allowed.
Wrap Up
You've now seen for yourself the kind of attack that cross-origin restrictions prevent, how this works, and how you can allow Cross-Origin Resource Sharing. Hopefully this gives you the fundamental understanding you need in order to ask the right questions next time you encounter a CORS issue.
As I mentioned at the start of this post, the problem I encountered loading a third-party script turned out to be an edge case. So what was the issue?
It turns out that script tags (and image and link tags) are generally exempt from the Same-Origin Policy. They are loaded with Request Mode no-cors
; in this mode the browser prevents the client-side JavaScript from accessing the response data. However, a content type of module
on the tag is an exception, and cross-origin restrictions are applied. This was the edge case that tripped us up, and simply changing the content type to application/javascript
allowed us to load the script without a CORS error.