The CORS Conundrum
Umang Sinha
Posted on February 11, 2024
If you're a back end developer you must have been in a position where the API you wrote worked perfectly fine when tested with Postman, cURL or any other API testing tool but as soon as the frontend application started consuming your API, the following much dreaded error started appearing:
If you've been there, this article is for you.
We will dive deep into CORS and explore what it is, why it is needed and how to deal with it.
What is CORS?
According to MDN Web Docs, "Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources."
If that didn't make a lot of sense to you, here's a diagram to simplify things:
When a website hosted at xyz.com sends requests to a web server also hosted at xyz.com (same domain, protocol and port), the request is a 'same-origin request'. These requests are generally allowed and have fewer restrictions. However, they are still subject to security mechanisms such as the Same Origin Policy (SOP).
When a website hosted at xyz.com sends requests to a web server hosted at abc.com, the request is a 'cross-origin request'. By default, web browsers restrict cross-origin requests to prevent unauthorized access to sensitive data or resources. However, there are mechanisms such as Cross-Origin Resource Sharing (CORS) that allow servers to explicitly authorize cross-origin requests from specific origins.
If the website hosted at xyz.com wants to fetch data from an API hosted at abc.com, the server at abc.com needs to include CORS headers in its response to allow requests from xyz.com.
Since the browser is the one who restricts cross-origin resource sharing, the API you built works in Postman, cURL and other API testing tools but not in the browser.
Why do browsers restrict cross-origin resource sharing?
Let us assume the following three scenarios:
- Person A gets tricked into clicking on a specially crafted link that they received over email or found embedded on a malicious website. Person A was logged into their bank account in the same browser session thereby allowing the malicious request to be executed and transferring funds from A's account to the attacker's account without A's knowledge.
- The attacker crafts a request to change the password of the victim's account on a web service. Person B is then lured into clicking a link embedded in a phishing email or disguised as a legitimate action. If Person B is logged into the targeted web service in the same browser session, the malicious request gets executed, thereby changing the password of the victim's account.
- Person C clicks on a phishing link that they received. Person C was also logged into their email account in the same browser session. The malicious request is thus executed and email forwarding gets configured without Person C's knowledge. All the incoming emails from Person C's email now start being forwarded to the attacker's email.
All the above mentioned scenarios are examples of CSRF (Cross-Site Request Forgery) attacks that could have been avoided if the server had CORS configured. Browsers restrict CORS to enforce SOP and mitigate security risks such as unauthorized access and CSRF attacks. By implementing proper CORS policies, web developers can control access to resources on their servers and ensure that sensitive operations are only allowed from trusted origins, thereby enhancing the overall security of their web applications.
So, how are CORS policies implemented on the server-side?
To demonstrate this I will be using a simple server I built using ExpressJS. The server supports three endpoints:
- /add-student - takes
id
andname
as inputs and adds a new student - /delete-student - takes
id
as input and deletes the student with thatid
- /get-student - takes
id
as input and fetches the student
There is also a simple frontend that we will be using to send these API requests. It was made using plain HTML and it looks something like this:
Here's the code for the client:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Student</title>
</head>
<body>
<input type="text" id="textField1" placeholder="Student ID" />
<input type="text" id="textField2" placeholder="Student name" />
<button onclick="addStudent()">Add student</button><br /><br />
<input type="text" id="textField3" placeholder="Student ID to delete" />
<button onclick="deleteStudent()">Delete student</button><br /><br />
<input type="text" id="textField4" placeholder="Get student" />
<button onclick="getStudent()">Get student</button><br /><br />
<script>
let apiUrl = "http://127.0.0.1:3000";
function addStudent() {
let studentId = document.getElementById("textField1").value;
let studentName = document.getElementById("textField2").value;
fetch(apiUrl + "/add-student", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: studentName, id: studentId }),
}).then((response) => {
if (response.status === 200) {
console.log(response);
} else {
const div = document.createElement("div");
div.innerText = "Something went wrong";
document.body.appendChild(div);
}
});
}
function deleteStudent() {
let idToDelete = document.getElementById("textField3").value;
fetch(apiUrl + "/delete-student", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: idToDelete }),
}).then((response) => {
if (response.status === 200) {
console.log(response);
} else {
const div = document.createElement("div");
div.innerText = "Something went wrong";
document.body.appendChild(div);
}
});
}
function getStudent() {
let idToSearch = document.getElementById("textField4").value;
fetch(apiUrl + "/get-student" + "?id=" + idToSearch, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((response) => {
if (response.status === 200) {
console.log(response);
} else {
const div = document.createElement("div");
div.innerText = "Something went wrong";
document.body.appendChild(div);
}
});
}
</script>
</body>
</html>
And the server:
const express = require("express");
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
let students = [];
app.get("/get-student", (req, res) => {
const idToSearch = req.query?.id;
for (let i = 0; i < students.length; i++) {
if (students[i]["id"] == idToSearch) {
return res.status(200).send({ student: students[i] });
}
}
return res.status(404).send("Not found");
});
app.post("/add-student", (req, res) => {
const id = req.body?.id;
const name = req.body?.name;
students.push({ id, name });
return res.status(200).send({ students, message: "Successfully added" });
});
app.delete("/delete-student", (req, res) => {
const idToDelete = req.body?.id;
for (let i = 0; i < students.length; i++) {
if (students[i]["id"] == idToDelete) {
students.splice(i, 1);
return res.status(200).send({ students });
}
}
return res.status(404).send("Not found");
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
And another simple server that serves the HTML file:
const express = require("express");
const app = express();
const port = 4000;
app.get("/", function (req, res) {
res.sendFile(__dirname + "/index.html");
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
The server is listening on port 3000 while the HTML file is being served at port 4000. Thus, any request sent from the client to the server in this case would be a Cross-Origin Request.
If we now try to add a student with id
101 and name
Alex we get the much dreaded CORS error as expected:
We did not configure CORS on our backend. The browser tried to send a preflight request to the server and did not receive appropriate headers in the response.
The browser first sends a preflight request to the server to determine if the actual request is safe to send. This preflight request is an HTTP OPTIONS request that includes specific headers, such as Origin
, Access-Control-Request-Method
, and Access-Control-Request-Headers
. The server must respond to the preflight request with appropriate CORS headers indicating whether the actual request is allowed. These headers include Access-Control-Allow-Origin
, Access-Control-Allow-Methods
, Access-Control-Allow-Headers
, and others.
Only after the browser receives a satisfactory response to the preflight request will it send the actual request (e.g., GET, POST, etc.). If the preflight request fails or if the server does not respond with the required CORS headers, the browser will block the actual request, preventing potential cross-origin security vulnerabilities.
Let us now create a middleware that will add the required CORS headers to the API response.
// CORS middleware
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "http://127.0.0.1:4000");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
// Allow specific methods
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
next();
});
The server code finally looks like this:
const express = require("express");
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
let students = [];
// CORS middleware
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "http://127.0.0.1:4000");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
// Allow specific methods
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
next();
});
app.get("/get-student", (req, res) => {
const idToSearch = req.query?.id;
for (let i = 0; i < students.length; i++) {
if (students[i]["id"] == idToSearch) {
return res.status(200).send({ student: students[i] });
}
}
return res.status(404).send("Not found");
});
app.post("/add-student", (req, res) => {
const id = req.body?.id;
const name = req.body?.name;
students.push({ id, name });
return res.status(200).send({ students, message: "Successfully added" });
});
app.delete("/delete-student", (req, res) => {
const idToDelete = req.body?.id;
for (let i = 0; i < students.length; i++) {
if (students[i]["id"] == idToDelete) {
students.splice(i, 1);
return res.status(200).send({ students });
}
}
return res.status(404).send("Not found");
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
Restart the server and try adding the student again. Now it works!
The preflight request received a response with all the required CORS headers this time and thus the browser sent the actual request.
The CORS Headers
In the middleware, we set the following three headers:
- Access-Control-Allow-Origin
- Access-Control-Allow-Headers
- Access-Control-Allow-Methods
Let us look at each of these headers in detail:
Access-Control-Allow-Origin:
If the server includes Access-Control-Allow-Origin: *
in the response header, it allows requests from any origin. This is not a good practice generally and is considered a security risk unless you are intentionally building a public API that should be accessible from any origin. It effectively disables the Same-Origin Policy, which is designed to protect against attacks, such as Cross-Site Request Forgery (CSRF).
If the server includes Access-Control-Allow-Origin: <origin>
in the response header, it allows requests only from the specified origin (it was set to http://127.0.0.1:4000 in the above example).
If the server does not include the Access-Control-Allow-Origin
header in the response (or includes it with a different origin), the browser will block the request due to the Same-Origin Policy.
Access-Control-Allow-Headers:
Along with the preflight request, the browser includes the Access-Control-Request-Headers
header, which lists the headers that the client wants to include in the actual request.
The server then responds to the preflight request, and if it allows the requested headers, it includes the Access-Control-Allow-Headers
header in the response. This header contains a comma-separated list of the headers that the server allows (we set this header to "Origin, X-Requested-With, Content-Type, Accept" in the above example).
Access-Control-Allow-Methods:
In the preflight request, the browser includes the Access-Control-Request-Method
header, which specifies the method that the client wants to use in the actual request. The server then responds to the preflight request, and if it allows the requested method, it includes the Access-Control-Allow-Methods
header in the response. This header contains a comma-separated list of the HTTP methods that the server allows (we set this header to "GET, POST, PUT, DELETE, OPTIONS" in the above example)
Conclusion:
If you feel this dive into CORS wasn't deep enough or want to explore further, MDN Web Docs would be the best place to continue reading: MDN Web Docs
The code used to demonstrate CORS in this article can be found here: GitHub
I hope this article helped you understand what CORS is and how you can deal with it the next time you see "CORS Error" flashing on your screen!
Posted on February 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.