CORSの探究

✍🏼 作成日 2016年07月07日   
❗️ 注意:この記事が作成されてから既に 日が経過しています。情報の鮮度にご注意ください

本文の経緯

あるオンライン記事でCORSについて調べていた際、『withCredentials = trueを設定すると、送信されるcookieはターゲットドメインのcookieである』という一文に困惑しました。私は理解できませんでした:現在のドメインがa.comで、xhrb.comに送信する場合、当然ソースドメインa.comcookieb.comに送信して処理するはずです。どうしてターゲットドメイン(ここではb.comと解釈)のcookieが送信されるのでしょうか?そこで調査を開始しました(結論から言うと、参照した資料の記述は正しく、実際にターゲットドメインb.comcookieが送信されていました)。

小目標-1: シンプルリクエストでa.comからb.comajaxを送信する

シンプルリクエストの定義については各自でGoogle/SOを参照してください。ここで重要な基本事実として、cookieIPアドレスではなくドメインに紐づきます。私はローカルでcookieを処理できる簡単なexpressサービスを立ち上げ、同時にVPS上にも同じサービスを用意し、hostsファイルを編集して異なるドメインを実現しました:

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

まずVPSサーバー上でexpressサービスを起動します。特に設定はせず、単純にheaderを返すだけです。ポートは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);

a.comのサーバー側もほぼ同じですが、ajaxを送信するための静的ページを追加しています。ポートは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);

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>

サーバー側でAccess-Control-Allow-Originを設定していないため、エラーが発生します:

VPSServerError

次にb.comのサーバー側でa.comからのajaxを許可する設定を追加します(ポートまで正確に指定する必要があります):

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);
});

再度リクエストを送信:

VPSServerError1

VPSServerError1.1

コンソールにエラーが表示されず、ステータスコードが200であることから、b.coma.com:9090からのリクエストを許可していることが確認できます。

小目標-2: ローカルサービスがフロントエンドからのcookieを受信する

まずローカルでa.comのバックエンドがフロントエンドからのcookieを取得できるかテストします:

テスト方法は簡単で、jsに適当なcookieを追加するだけです:

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

LocalServer1

ローカルコンソールのApplicationタブでcookieが存在することを確認できます。次にバックエンドの出力を確認:

LocalServer2

問題ありません。www.a.com:9090にアクセスした際に確実にcookieが付与されています。予想通りです。

小目標-3: a.comcookieb.comに送信する

この時点でa.comのページにはcookieが存在します。そこで再度ボタンをクリックし、ajaxリクエストでcookieb.comに送信されるか確認します:

VPSServerError1.2

cookieを追加しなかった場合と同様に、a.comからのcookieは取得できません。これはセキュリティ制限によるもので、予想通りの結果です。

追加の小目標: 非シンプルリクエスト

ここでシンプルリクエストに関するテストを挿入します。xhrに新しいheaderを追加し、再度リクエストを送信します:

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();
}

今回はAccess-Control-Allow-Originを追加した後の操作なので、ブラウザは異なるエラーを返します:

VPSServerError2.1

気づいたのはやはり Access-Control-Allow-Origin のエラーでしたが、今回はフロントエンドでカスタム header を設定していたため、非シンプルリクエストとなっていました。非シンプルリクエストの場合、まずプリフライトリクエスト(prelight)が送信され、リクエストタイプは OPTIONS です。詳細はこの記事](リンク)をご覧ください。プリフライトリクエストの目的は、b.com のサーバーがこの xiaodan という header を受け入れるかどうかを確認することです。バックエンドが返す headerAccess-Control-Alow-Headers に、xiaodan という値が含まれていなかったため、エラーが発生しました。

次に、b.com が返す内容に適切な header を追加してみます:

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);
});

再度リクエストを送信してみます:

VPSServerError2.1

なんと同じエラー結果です。サーバーは 200 を返していますが、適切な Access-Control-Allow-Headers を返していないため、ブラウザが結果を拒否しました(サーバーが拒否したわけではなく、サーバーは 200 を返しています)。

調査してみると、問題はこの非シンプルリクエストにあることがわかりました。b.com の関数を少し修正します:

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);
});

サーバー側:

VPSServerError2.2

クライアント側:

VPSServerError2.3

原因を分析すると(確認待ち、後で HTTP の権威あるガイドを参照します)、非シンプルリクエストの prelight リクエストは実際のリクエストを送信せず、まずプリフライトリクエストを送信して、サーバーが特定の非シンプル header フィールドをサポートしているかどうかをテストします。つまり、非シンプルヘッダーを含むリクエストは app.get('/') の中には到達しません。同時に、b.com のサーバーでは、console.log(req.headers)app.get('/') の中に書かれているため、先ほどのリクエストでは b.com サーバーは何も出力していません。これもこの点を裏付けています。この設計は、CORS標準を理解していない古いサーバーを保護するために、サーバーがCORS標準を認識していることを確認することを目的としています

OK、中断はここまで。クライアント側、つまり a.com で発行される xhr リクエストのページで withCredentials = true を設定(xhrSend 部分のみを記載)して、a.comcookieb.com に送信できるかテストします(ここではシンプルリクエストと非シンプルリクエストは同じ結果です。違いを確認しやすくするために、xhr で設定した header を削除しました):

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

今回のエラーメッセージが変わり、サーバーが Access-Control-Allow-Credentialstrue に設定していないという内容になりました。この header はリクエストに cookie を含めることを許可するためのものです。そこで設定を追加します:

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

まだありません。Chromecookie を確認します:

VPSServerError2.6

確かに cookie は設定されているはずです。どういうことでしょうか? 納得できず、req.headersexpress でフォーマットされたものなので、生の headers である rawHeaders を確認します:

VPSServerError2.7

まだ見つかりません。cookie はどこに行ったのでしょうか?

どちらもない、彼女は将来見つけると言った、時間、時間が私に答えをくれる ------私のスケート靴

問題が見つからない時は、アイスクリームを食べましょう。階下でパクチー味のネスレアイスを買って(ハーゲンダッツは高くて手が出ません)、階段を上っている時にひらめきました。もしかしてこれはサードパーティの cookie で、ブラウザのトラッキングをブロックしているせいかもしれません。アイスを食べ終わった後、Chrome の設定で「“Do Not Track” リクエストをブラウジングトラフィックと共に送信する」のチェックを外しました:

VPSServerError2.8

ちなみに、今回は万が一に備えて(実際には万が一はありませんが)req.headersapp.use の中に置きました:

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);
});

再度ボタンをクリックしてリクエストを送信し、Chrome のコンソールと b.com のサーバー出力を確認します:

非シンプルヘッダーがあるため、前回と同様に2つのリクエストが表示されます:

VPSServerError2.9

VPSServerError2.9.1

サーバー側でも受信されていないため、この Chrome 設定とは無関係です。変数を制御するため、「Do Not Track」は前回と同じように再度チェックを入れました。サーバー側(GET リクエストのみ):

VPSServerError2.9.2

それでも Cookie フィールドがありません。なぜでしょうか?

記事の冒頭で述べた言葉を思い出しました: 「注意: withCredentials = true を設定すると、送信される cookie はターゲットドメインの cookie です」。もしかして、a.com からボタンをクリックして b.com にリクエストを送信する場合、b.com に設定された cookie が送信されるのでしょうか?

そこでまず、b.com のサーバーにも index.html を作成し、適当な cookie を設定してみます:

b.com のサーバーコード:

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 の index.html コード:

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、まず b.com にアクセスします:

VPSServerError4

問題ありません。正常にページが返され、cookie も設定されます。次に、a.com からボタンをクリックしてリクエストを送信します:

VPSServerError4.1

a.com から送信された ajax リクエストに、b.comcookie が含まれていることが確認できます。

記事の冒頭の言葉が実証されました。

a.com から b.comcookie を送信できるなら、フロントエンドで b.comcookie を取得できるでしょうか?

ドキュメントを確認すると、ajax には getAllResponseHeaders()getResponseHeader() という2つのインターフェースがあり、サーバー側には Access-Control-Expose-Header があります。そこでテストしてみました(あえて分けて出力します)。

まずは簡単な方から、xhrgetAllResponseHeaders() インターフェースを呼び出します:

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

headerCookie フィールドが表示されませんでした。予想通りです。もしかして getAllResponseHeaders()header を走査しても Cookie が表示されないのは、enumerable: false に設定されているからかもしれません。そこで 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

これも予想通りでした。サーバー側で公開する header コンテンツが設定されていないためです。そこで b.comAccess-Control-Expose-Header を設定しました:

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',
});

再度 getResponseHeader('Cookie')getAllResponseHeaders() を実行する

LocalServer3.2

エラーはなくなったが、それでも b.comcookie を取得できません。b.com のサーバー側がすべて許可していてもダメです。

調べてみると、この Access-Control-Expose-Header はカスタム header にしか設定できず、フロントエンドで取得できるようになるだけだとわかりました。しかし、これはあまり意味がありません。なぜなら、このカスタム header はフロントエンドで設定したもので、唯一の用途はバックエンドがカスタム header を変更/新規作成した後にフロントエンドが取得することだからです。以下では、バックエンドに header を設定し、フロントエンドで取得できるようにします:

b.comserver:

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.comindex.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

この通りです。

したがって、この小さな目標は達成できませんが、SO コミュニティではサードパーティサービスを利用したりバックエンドで転送したりするなどの解決策が提案されています。結局のところ、ルールは固定的ですが、人間は柔軟に対応できます。jsonp と同じように、ですよね。

連想

「数ヶ月のトレーニングでゼロからある言語をマスターできる」と言う人がいますが、私は夢物語だと思います。なぜなら、コンピュータの基礎知識がなく、バイナリ/コンパイル原理/コンピュータ原理/OS原理/ネットワーク基礎/通信プロトコルが何なのかわからない状態でコードを書けるということは、単に模倣する学習能力が高いだけで、そう書くとそうなることは知っていても、なぜそう書くとそうなるのかは理解していないからです。

したがって、コンピュータと向き合うときは、知識の幅が広ければ広いほどよく、深ければ深いほど良いのです。このように、CORS を理解しているうちに、以前触れた AUTH を思い出しました。

AUTH2.0 というものがありますが、それが CORS と何の関係があるのでしょうか?実は関係ありませんが、以下は私の比較です:

CORS は、B サイトにアクセスしたユーザーが、A サイトからリクエストを送信する際に B サイトの cookie を携帯できるようにします。手順は次のとおりです:

  1. ユーザーが B サイトにアクセスする。
  2. ユーザーが A サイトにアクセスし、A サイトから B サイトにリクエストを送信する。
  3. B サイトは、A サイトからのリクエストに含まれる B サイトの cookie を検証し、問題がなければ B サイトのデータを返す。

AUTH は、ユーザーが A ウェブサイトから B ウェブサイトのリソースにアクセスできるようにします。ただし、ユーザーの承認が必要です。手順は次のとおりです:

  1. A ウェブサイトでリクエストを送信する。
  2. ステップ 1 で B サイトにリダイレクトされ、承認を確認した後、A ウェブサイトに戻る。
  3. この時点で A ウェブサイトは tooken(トークン) を取得し、ユーザーの B ウェブサイトのリソースにアクセスできるようになる。

似ていませんか?

CORS の最初のステップは AUTH の 2 番目のステップに対応します。AUTH の 2 番目のステップは、CORS の最初のステップと見なすことができ、B ウェブサイトのサーバー側に Access-Control-Allow-Credentials を追加したと考えることができます。これにより承認が完了したことになります。

CORScookie を携帯するのは、AUTH の簡易版と考えることができます。

以下は RFC 6749 からの抜粋です:

AUTH

後書き

なぜサードパーティ広告のcookieがプライバシーを漏洩すると言われるのでしょうか?これは、広告がAというウェブサイトに掲載されると、広告主はその広告がAサイトに配信されたことを知る(広告配信のid/keyなどのidentityで識別し課金される)ためです。そこで広告主はこの広告にcookieを設定します。この広告は広告Bのドメインから提供されるため、設定されるcookieは当然Bcookieになります。Aサイトがこの広告を読み込むたびに、必ずjsを実行します。このjsの中で、Aサイトがcookieを送信することを許可し、同時にBサイトもAサイトからのcookieBサイトのcookieを添えて送信することを許可します。これにより、すべてが把握されてしまうのです。

`Google AD Impl:

googleWithCredientials

googleWithCredientials2

注意事項

  1. 上記の変更にはサーバー側の修正が含まれており、いずれもサービスの再起動が必要です。VPS上でサービスを再起動するのは不便であり、時間が経つと接続が切れてしまうため、最善の方法はVPS上に記事中のa.com(主にindex.htmlを修正するため)を配置し、ローカルに記事中のb.comの内容(主にindex.jsを修正するため)を配置することです。

  2. リモート接続を維持する最も簡単な方法は、それをバックグラウンドで実行することです(接続を維持しないと、接続が切れた後にリクエストが来てI/O操作を行っても、やはり切断されます)。node index.js &を実行するか、すでにnode index.jsを実行している場合はctrl+zを押してバックグラウンドで凍結し、bgコマンドで最新のバックグラウンドタスクをアクティブにします。jobsコマンドで現在のタスクリストを確認できます。現在のsessionから退出した後、再度接続するとタスクはまだ実行中ですが、jobsではそのタスクが見えなくなるため、ps -Aですべてのプロセスをリストアップし、kill IDnodeのプロセスを終了させてから再実行します。

  3. 小目標の達成中にエラーが発生した際、カスタムフィールドをX-で始めなかったためにエラーになったのではないかと疑いましたが、標準を確認したところX-で始めるという規定はなく、ウィキペディアやSOではカスタムHeaderX-で始めることを推奨しているだけでした。

  4. withCredentials = trueを設定した後は、サーバー側のAccess-Control-Allow-Originwildcard*に設定することはできなくなります。

- EOF -
この記事の初出: CORSの探究 - Xheldon Blog