My Exploration of CORS

✍🏼 Written on Jul 7, 2016   
❗️ Note: it has been days since this article was written, please be aware of its timeliness

The Origin of This Article

While reading an online resource about CORS, I was confused by a statement: “Note that after setting withCredentials = true, the cookie carried is the target domain’s cookie.” I was puzzled: If the current domain is a.com sending an xhr to b.com, shouldn’t it be the source domain a.com’s cookie that’s sent to b.com for processing? How could it be the target domain’s (which I assumed to be b.com) cookie? Thus, I began my investigation (spoiler: the statement in the material I read was indeed correct—it does carry the target domain b.com’s cookie).

Mini-Goal 1: Make a.com Send an ajax Request to b.com Under Simple Requests

What constitutes a simple request can be found via Google/Stack Overflow. Here’s a key fact: cookies are tied to domains, not IP addresses. I set up a simple express service locally that can handle cookies, and another identical service on my VPS, then used hosts file modifications to simulate different domains:

1
2
3
4
5
// 修改 hosts 如下
// 本机 ip
172.16.26.57 www.a.com
// VPS ip
45.78.41.32 www.b.com

First, I launched an express service on the VPS server with no special configurations, simply returning the header, running on port 9091:

1
2
3
4
5
6
7
8
var express = require('express');
var app = express();

app.get('/', function (req, res, next) {
res.send(req.headers);
});

app.listen(9091);

The a.com server setup was largely the same, except I added a static page to send ajax requests, running on port 9090:

1
2
3
4
5
6
7
var express = require('express');
var app = express();
var path = require('path');
app.get('/', function (req, res) {
res.sendFile(path.join(__dirname, 'index.html'));
});
app.listen(9090);

Content of index.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>
<head>
<title>a.com</title>
</head>
<body>
<button id="button">点我发请求, 打开控制台查看信息</button>
<script type="text/javascript">
var button = document.getElementById('button');

function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com:9091', true);
xhr.send();
}

button.addEventListener('click', xhrSend);
</script>
</body>
</html>

Since the server didn’t set Access-Control-Allow-Origin, an error occurred:

VPSServerError

Next, I added permission for ajax requests from a.com on the b.com server (requiring precise port specification):

1
2
3
4
5
6
app.get('/', function (req, res, next) {
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
});
res.send(req.headers);
});

Sent the request again:

VPSServerError1

VPSServerError1.1

The console showed no errors, and the status code 200 confirmed that b.com now allowed requests from a.com:9090.

Mini-Goal 2: Local Service Receives Frontend cookies

Next, I tested whether the a.com backend could receive cookies from the frontend locally:

The test method was simple—just add some arbitrary cookies in the js:

1
2
3
document.cookie = 'domain=a.com;';
document.cookie = 'name=xheldon';
document.cookie = 'lover=xiaodan';

LocalServer1

The Application tab in the local console confirmed the presence of the cookie. Checking the backend output:

LocalServer2

No issues here. Visiting www.a.com:9090 indeed included the cookie, as expected.

At this point, the a.com page had cookies, so I clicked the button again to see if the ajax request could pass the cookie to b.com:

VPSServerError1.2

Just like before adding the cookie, b.com didn’t receive a.com’s cookie. This was due to security restrictions and was expected.

Bonus Mini-Goal: Non-Simple Requests

Here’s a quick test about simple requests. I added a new header to the xhr and sent the request again:

1
2
3
4
5
6
7
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com:9091', true);
xhr.setRequestHeader('xiaodan', 'xheldon');
xhr.send();
}

Since this was done after setting Access-Control-Allow-Origin, the browser reported a different error this time:

VPSServerError2.1

I noticed it was still due to the Access-Control-Allow-Origin error, but this time it was because the frontend had set a custom header, making it a non-simple request. For non-simple requests, a preflight request (prelight) is first sent with the request type OPTIONS. You can check out this article for more details. The purpose of the preflight request is to “check in” with the b.com server to see if it accepts this xiaodan header. The backend did not include the value xiaodan in the returned header Access-Control-Allow-Headers, hence the error.

Next, we added the corresponding header to the response from b.com:

1
2
3
4
5
6
7
8
app.get('/', function (req, res, next) {
console.log(req.headers);
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
});
res.send(req.headers);
});

Then we made the request again:

VPSServerError2.1

Surprisingly, the same error persisted. Although the server responded with 200, it did not return the corresponding Access-Control-Allow-Headers, so the response was rejected by the browser (note: not by the server, as the server did return 200).

After some troubleshooting, I realized the issue lay with this non-simple request. I made a slight modification to the b.com function:

1
2
3
4
5
6
7
8
9
10
11
12
app.use(function (req, res, next) {
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
});
next();
});

app.get('/', function (req, res, next) {
console.log(req.headers);
res.send(req.headers);
});

Server-side:

VPSServerError2.2

Client-side:

VPSServerError2.3

The root cause (to be verified—I’ll revisit the HTTP authoritative guide later) seems to be that the prelight request for non-simple requests does not initiate the actual request. Instead, it first sends a preflight request to test whether the server supports a particular non-simple header field. In other words, requests with non-simple headers do not reach app.get('/'). This is confirmed by the fact that console.log(req.headers) is written inside app.get('/'), and the b.com server did not output anything for the recent request. This design ensures the server is aware of the CORS standard to protect legacy servers that do not support CORS.

Alright, intermission over. Let’s test whether setting withCredentials = true in the client (i.e., the xhr request initiated from the a.com page—only the xhrSend part is shown here) can send a.com’s cookie to b.com (the result is the same for simple and non-simple requests; for clarity, I removed the header set in xhr):

1
2
3
4
5
6
7
8
// 设置允许 cookie
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com/', true);
xhr.withCredentials = true;
xhr.send();
}

VPSServerError2.4

This time, the error message changed: the server did not set Access-Control-Allow-Credentials to true. This header is used to allow requests to carry cookies, so let’s set it:

1
2
3
4
5
6
7
8
9
10
11
12
13
app.use(function (req, res, next) {
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
});
next();
});

app.get('/', function (req, res, next) {
console.log(req.headers);
res.send(req.headers);
});

VPSServerError2.5

Still nothing. Let’s check Chrome’s cookie:

VPSServerError2.6

The cookie is indeed set—what’s going on? Unconvinced, I thought req.headers might be formatted by express, so I checked the raw headers rawHeaders:

VPSServerError2.7

Still nothing. Did the cookie get eaten by a dog?

None of it, she said I’d find it someday, time, time will give me the answer ——— My Skate Shoes

OK, when you can’t find the problem, just eat an ice cream. I went downstairs and bought a Nestlé ice cream with cilantro flavor (can’t afford Häagen-Dazs), and while going upstairs, a light bulb moment hit me—maybe ours is a third-party cookie issue, could it be caused by me disabling browser tracking? So after finishing the ice cream, I went into chrome settings and unchecked the box for Send a "Do Not Track" request with your browsing traffic:

VPSServerError2.8

By the way, this time I put req.headers inside app.use just in case (though there really shouldn’t be any “just in case”):

1
2
3
4
5
6
7
8
9
10
11
12
13
app.use(function (req, res, next) {
console.log(req.headers);
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
});
next();
});

app.get('/', function (req, res, next) {
res.send(req.headers);
});

Clicked the button again to send the request, then checked the chrome console and b.com server output:

Because of non-simple headers, it showed two requests as before:

VPSServerError2.9

VPSServerError2.9.1

The server didn’t receive it either, which means it’s unrelated to this Chrome setting. So to control variables, I rechecked the Do Not Track option to match the previous state. Server-side (only showing the GET request):

VPSServerError2.9.2

Still no Cookie field. Why?

Then I remembered the sentence at the beginning of the article: “Note: After setting withCredentials = true, the cookie carried is the target domain’s cookie.” Could it be that when I click the button on a.com to send a request to b.com, it’s sending b.com’s cookie?

So I first created an index.html on the b.com server and added some random cookie:

b.com server code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var express = require('express');
var app = express();
var path = require('path');
app.use(function (req, res, next) {
console.log(req.headers);
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
});
next();
});

app.get('/', function (req, res, next) {
res.sendFile(path.join(__dirname, 'index.html'));
});

app.listen(9091);

b.com’s index.html code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<title>a.com</title>
</head>
<body>
<div>
open Application inspector to check whether the cookie is be setting
</div>
<script type="text/javascript">
document.cookie = 'yes=you_cant_believe_i_from_b.com';
document.cookie = 'from=this_is_from_b.com';
</script>
</body>
</html>

OK, let’s first visit b.com:

VPSServerError4

No issues, the page loads normally, and the cookie is set correctly. Next, on a.com, I clicked the button to send the request:

VPSServerError4.1

You can see that the ajax request initiated from a.com carried b.com’s cookie.

The statement at the beginning of the article was confirmed.

Since a.com can send b.com’s cookie, can the frontend also retrieve b.com’s cookie?

Checked the documentation—ajax has two interfaces: getAllResponseHeaders() and getResponseHeader(), and the server has Access-Control-Expose-Header. So I tested it (I just want to output them separately, so what?).

Starting with the simpler one, first calling xhr’s getAllResponseHeaders() interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 设置允许 cookie
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com/', true);
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (this.status === 200) {
console.log('AllRes:', this.getAllResponseHeaders());
}
};
xhr.send();
}

LocalServer3

No Cookie field appeared in the header, as expected. Just in case getAllResponseHeaders() didn’t traverse the header because Cookie was set to enumerable: false, I tried getResponseHeader():

1
2
3
4
5
6
7
8
9
10
11
12
13
// 设置允许 cookie
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com/', true);
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (this.status === 200) {
console.log('Res:', this.getResponseHeader('Cookie'));
}
};
xhr.send();
}

LocalServer3.1

Still as expected, because the server didn’t expose the header content. So I set Access-Control-Expose-Header on b.com:

1
2
3
4
5
6
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
'Access-Control-Expose-Headers': 'Cookie',
});

Then execute getResponseHeader('Cookie') and getAllResponseHeaders() again.

LocalServer3.2

No errors now, but still unable to obtain the cookie from b.com, even though the b.com server has granted permission.

After researching, I learned that Access-Control-Expose-Header can only be set to custom headers for the frontend to access. But this seems pointless, because these custom headers are set by the frontend in the first place. The only use case is for the backend to modify/create custom headers and then have the frontend retrieve them. Below, I’ll set a header on the backend for the frontend to fetch:

b.com’s server:

1
2
3
4
5
6
7
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
'Access-Control-Expose-Headers': 'Xheldon',
Xheldon: 'MyNameIsXheldon',
});

a.com’s index.html:

1
2
3
4
5
6
xhr.onreadystatechange = function () {
if (this.status === 200) {
console.log('Res:', this.getResponseHeader('Xheldon'));
console.log('AllRes:', this.getAllResponseHeaders());
}
};

LocalServer3.3

That’s it.

So this small goal is unachievable, but the SO community offers some solutions, like using third-party services/backend forwarding. After all, rules are rigid, but people are flexible—just like jsonp, right?

Reflection

Some claim that zero-basis beginners can master a programming language in just a few months of training. I think that’s a fantasy. Without foundational computer knowledge—without understanding concepts like binary/compilation principles/computer fundamentals/operating system basics/network foundations/communication protocols—being able to write code only proves you’re good at copying examples. You know that writing X produces Y, but you don’t know why writing X results in Y.

Therefore, when working with computers, the broader your knowledge, the better, and the deeper your understanding, the better. For instance, while learning about CORS, I recalled my earlier exposure to AUTH.

There’s something called OAuth 2.0. What’s its relationship with CORS? None, really. But here’s my comparison:

CORS allows users who have visited Site B to carry B’s cookie when making requests from Site A. The steps are:

  1. The user visits Site B.
  2. The user visits Site A and initiates a request to Site B from A.
  3. Site B verifies the cookie from B in the request from A. If valid, it returns B’s data.

OAuth allows users to access resources on Site B from Site A, but requires user authorization. The steps are:

  1. Initiate a request from Site A.
  2. Step 1 redirects to Site B for authorization confirmation, then redirects back to Site A.
  3. Now, Site A obtains a token and can access the user’s resources on Site B.

See the resemblance?

The first step of CORS corresponds to the second step of OAuth. The second step of OAuth is analogous to the first step of CORS—you could say it’s like adding Access-Control-Allow-Credentials on Site B’s server, effectively completing authorization.

You could consider CORS with cookie as a simplified version of OAuth.

Excerpt from RFC 6749:

AUTH

Postscript

Why do third-party advertising cookies leak privacy? This is because when an ad is placed on website A, the advertiser knows the ad is displayed on A (identified and paid for through ad placement id/key or similar identity). The advertiser then sets a cookie on this ad. Since the ad originates from the domain of advertiser B, the cookie set naturally belongs to B. Every time website A loads this ad, it must execute a piece of js. In this js, it is configured to allow A to send cookies, while B also permits cookies from A to carry B’s cookies back. Thus, everything becomes known.

`Google AD Impl:

googleWithCredientials

googleWithCredientials2

Notes

  1. The above modifications involve server-side changes, all of which require a service restart. Since restarting services on a VPS is inconvenient and connections may time out, the best approach is to place the a.com content from the article on the VPS (primarily for modifying index.html), while hosting the b.com content locally (mainly for modifying index.js).

  2. To keep remote connections from disconnecting, the simplest method is to run them in the background (provided the connection remains active; otherwise, if the connection drops, incoming requests performing I/O operations will still be terminated). You can run node index.js &, or if node index.js is already running, press ctrl+z to suspend it to the background, then execute the bg command to resume the most recent background task. The jobs command lists current tasks. If you exit the current session and reconnect later, the task will still be running, but jobs won’t display it. In this case, use ps -A to list all processes, then kill ID to terminate the node process and restart it.

  3. During the completion of small goals, I suspected errors occurred because custom fields weren’t prefixed with X-. Upon checking the standards, I found no requirement for X- prefixes—Wikipedia and Stack Overflow merely recommend custom Headers to start with X-.

  4. After setting withCredentials = true, the server’s Access-Control-Allow-Origin can no longer use the wildcard *.

- EOF -
Originally published at: My Exploration of CORS - Xheldon Blog