最近、フロントエンドとバックエンドのインタラクションを行っているときに、特別なクロスドメイン問題に遭遇しました。実際、普段プロジェクトを進める際にも多少はクロスドメイン問題を扱ってきましたが、今回は状況が特異でした。この機会にクロスドメイン問題の解決方法をまとめておこうと思います。
シチュエーション#
少し前にフロントエンドを学んでいたので、この機会に自分で小さなツールを作って遊んでみようと思いました。その結果、以下の非常に簡素な弾幕サイトができました。
弾幕を持続的に保存するために、フロントエンドとバックエンドのインタラクションを試み、弾幕をデータベースに保存し、フロントエンドは一定の時間ごとにバックエンドからデータを取得することにしました。
実装自体は比較的簡単でしたが、ローカルでバックエンドにリクエストを送信し始めたときに問題が発生しました:
明らかにこれはクロスドメイン問題ですので、次に私たちが学んだ知識を使って解決していきます。
まず第一歩として、問題の原因を分析します:クロスドメインとは何ですか?
クロスドメインとは?#
クロスドメインとは、あるドメイン下の文書やスクリプトが別のドメイン下のリソースを要求しようとすることを指します。
クロスドメインは実際にはバグではなく、ブラウザが安全のために一連の「同一オリジンポリシー」を指定しているためです。これらの同一オリジンポリシーは、ユーザー情報の安全を保証し、悪意のあるサイトによるデータの盗難を防ぐことができます。
このポリシーは、ウェブページの特定の行動が自分と「同一オリジン」のウェブページ内でのみ行えるように制限します。同一オリジンでない場合、その行動は無効になり、つまりクロスドメイン失敗となります。
ここで二つの概念が登場します。一つは同一オリジン、もう一つはクロスドメイン行動です。
同一オリジンの定義は三つの側面を含みます。
- プロトコルが同じ
- ドメイン名が同じ
- ポートが同じ
この三つの条件をすべて満たす場合、二つのウェブページは「同一オリジン」と認定されます。
クロスドメイン行動(私が定義した用語で、同一オリジンポリシーの影響を受ける操作のこと)は、インターネットの発展とともに範囲が広がり、一般的に見られるクロスドメイン行動には以下が含まれます。
- Cookie、LocalStorage、IndexDB の取得
- DOM および JS オブジェクトの取得
- AJAX リクエスト
フロントエンドとバックエンドのインタラクションで最も一般的な AJAX リクエストもここに含まれ、フロントエンドとバックエンドのインタラクションでクロスドメイン問題を解決することは避けられません。
では、なぜ同一オリジンポリシーを定義する必要があるのでしょうか?クロスドメイン制限がなければもっと良いのでは?
クロスドメイン制限がなければ、ウェブページは XSS や CSRF などの攻撃を容易に受けることになります。制限がないため、悪意のあるサイトも自由に攻撃を仕掛けることができ、これによりウェブサイトの維持コストが大幅に増加します。したがって、同一オリジンポリシーは実際には二刀流の剣であり、ウェブページを保護する一方で、時には味方を誤傷することもあります。
問題の根源を理解したので、対策を講じることができます。
解決策#
クロスドメイン問題の鍵は、私たちの Mリクエストが制限範囲内にあること、同一オリジンを達成していないことにあります。
重要な点はすでに示されていますが、実際に私たちの解決方法はこの二つのアプローチから着手します。
- 同一オリジンポリシーに含まれない行動を採用する
- 行動を同一オリジンの状態にする方法を考える
ここでは AJAX リクエストに焦点を当てています。他の Cookie や iframe などのクロスドメインの解決策については、関連するブログを参考にすれば必ず答えが得られるでしょう。
1.JSONP クロスドメイン#
AJAX リクエストで JSONP タイプを定義することでクロスドメインを実現できますが、JSONP は本質的に AJAX とは全く異なるリクエスト方式を採用しています。
従来の AJAX リクエストは実際にはxhr
の非同期リクエストですが、JSONP は本質的に<script>
タグを構築し、script
タグ内のsrc
が同一オリジンポリシーの制限を受けないことを利用します。src
にバックエンドのURL
を記入し、コールバック関数を追加することで、取得したデータはコールバック関数で処理されます。
参考阮一峰のブログ、実現思想は大体以下の通りです:
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute("type","text/javascript");
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
addScriptTag('http://example.com/ip?callback=foo');
}
function foo(data) {
console.log('response data: ' + JSON.stringify(data));
};
<script>
要素がリクエストしたスクリプトは、直接コードとして実行されます。この時、ブラウザがfoo
関数を定義していれば、その関数は即座に呼び出されます。引数としての JSON データは文字列ではなく JavaScript オブジェクトとして扱われるため、JSON.parse
のステップを回避できます。
AJAX での実装方法:
$.ajax({
url: 'http://www.domain2.com:8080/login',
type: 'get',
dataType: 'jsonp', // リクエスト方式はjsonp
jsonpCallback: "handleCallback", // カスタムコールバック関数名
data: {}
});
Vue での実装方法:
this.$http.jsonp('http://www.domain2.com:8080/login', {
params: {},
jsonp: 'handleCallback'
}).then((res) => {
console.log(res);
})
実現原理から見ると、JSONP には欠点があり、JSONP を使用する場合は GET リクエストでなければならず、POST リクエストでクロスドメインを実現するには他の方法を使用する必要があります。
2.WebSocket#
websocket
自体は通信プロトコルの一種であり、websocket
通信を通じて、実際には同一オリジンポリシーを回避し、ある意味で「同一オリジン」を実現できます。
以下はwebsocket
リクエストの HTTP ヘッダー情報で、Origin
フィールドに注目することが重要です。これはクロスドメインを実現する鍵です。
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
Origin フィールドはリクエストの発信元を示し、Origin フィールド内のドメイン名とリクエストの目的ドメイン名が同じであれば、同一オリジンポリシーに基づいてクロスドメインを実現できます。
通信が許可されると、WebSocket のレスポンスヘッダーは以下のようになります。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
3.CORS#
CORS は「クロスオリジンリソースシェアリング」(Cross-origin resource sharing)の略で、ブラウザがクロスオリジンサーバーに XMLHttpRequest リクエストを送信できるようにし、AJAX が同一オリジンでのみ使用できる制限を克服します。
これはクロスドメイン問題を解決するための一般的な方法です。
実現原理#
その実現原理は以下の図の通りです。
CORS リクエストは主に二つのタイプに分かれます:簡単リクエストと非簡単リクエスト。
以下の条件を満たす場合は簡単リクエスト、それ以外は非簡単リクエストです。
(1) リクエストメソッドは以下の三つのいずれか:
HEAD
GET
POST
(2)HTTP ヘッダー情報は以下のフィールドを超えない:
Accept
Accept-Language
Content-Language
Last-Event-ID
Content-Type
:三つの値application/x-www-form-urlencoded
、multipart/form-data
、text/plain
のみに制限されます。
簡単 CORS リクエストは、リクエスト時に HTTP ヘッダーに Origin フィールドを追加するだけです。
非簡単 CORS リクエストの場合、ブラウザは正式な通信の前に事前検証リクエストを送信し、サーバーがリクエストを許可するかどうかを確認し、応答を受け取った後、関連フィールドをチェックして正式なリクエストを発信します。
例えば、以下のような JS スクリプトを発信します。
var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();
事前検証リクエストのリクエストメソッドは OPTIONS で、具体的なリクエストヘッダーは以下のようになります。
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
主に注目すべき三つのフィールドがあります。
-
Origin
リクエストがどの源から来たかを示します。
-
Access-Control-Request-Method
このフィールドは必須で、ブラウザの CORS リクエストで使用される HTTP メソッドを列挙します。上記の例では
PUT
です。 -
Access-Control-Request-Headers
このフィールドはカンマ区切りの文字列で、ブラウザの CORS リクエストで追加送信されるヘッダーフィールドを指定します。上記の例では
X-Custom-Header
です。
以下のような応答を受け取ることで、クロスドメインリクエストが許可されていることを確認できます。
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
ここで
//任意のクロスドメインリクエストをサポート
Access-Control-Allow-Origin: *
//クロスドメインリクエストをサポートするメソッド
Access-Control-Allow-Methods: GET, POST, PUT
//ブラウザのリクエストにAccess-Control-Request-Headersが含まれている場合に必須で、サポートされるヘッダーフィールドを示します
Access-Control-Allow-Headers: X-Custom-Header
//Cookieや認証情報の送信を許可
Access-Control-Allow-Credentials: true
//この事前検証リクエストの有効期限を指定
Access-Control-Max-Age: 1728000
実現方法#
ここでは主にバックエンドの操作について、java
springboot
のクロスドメイン方式をサンプルとして使用します。
@Configuration
public class CORSConfiguration {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedHeaders("*")
.allowCredentials(true)
.allowedMethods("GET", "POST", "DELETE", "PUT","PATCH")
.maxAge(3600);
}
};
}
}
実際には、サーバーが応答する際にリクエストヘッダーを追加するだけで、springboot
は異なるcontroller
でアノテーションを使用して追加することもサポートしています。
4.nginx プロキシクロスドメイン#
この原理も簡単で、実際にはフロントエンドとバックエンドを同一オリジンに置き、nginx のリバースプロキシを利用してリクエストのドメイン名やポートを変更したり、Cookie 情報を追加したりしてクロスドメインを実現します。
#proxyサーバー
server {
listen 81;
server_name www.domain1.com;
location / {
proxy_pass http://www.domain2.com:8080; #リバースプロキシ
proxy_cookie_domain www.domain2.com www.domain1.com; #Cookie内のドメインを変更
index index.html index.htm;
# webpack-dev-serverなどのミドルウェアを使用してnginxにプロキシアクセスする場合、ブラウザが関与しないため、同一オリジン制限がないので、以下のクロスドメイン設定は無効にできます
add_header Access-Control-Allow-Origin http://www.domain1.com; #フロントエンドがクロスドメインでCookieを持たない場合は、*にできます
add_header Access-Control-Allow-Credentials true;
}
}
また、いくつかのミドルウェアのプロキシ方式もその原理は同じで、ここでは詳しく述べません。
問題分析#
一通りの分析#
上記の解決策について多くのことを述べましたが、最終的には私たちの問題に戻る必要があります。まず、エラーログをもう一度確認します:
うん?想像していたのとは少し違うようです。一般的なクロスドメイン問題は以下のようなものであるべきです:
これを見ると、何かが怪しいですね?
案の定、バックエンドにクロスドメイン設定を追加しても、エラーメッセージは依然として変わりませんでした。
この時、エラーメッセージをしっかり分析する必要があります(実際、これはログ分析の第一歩であり、クロスドメイン解決策を強引に導入するために、分析を後に回したのですが)
この文が私の注意を引きました。
クロスオリジンリクエストは、プロトコルスキーム:http、data、chrome、chrome-extension、https に対してのみサポートされています。
資料を調べたところ、実は私のフロントエンドとバックエンドのインタラクションはすべてローカルで実現されており、ローカルで HTML を開く際に使用しているのは file プロトコルですが、file プロトコルのリクエストはブラウザに認識されないことが分かりました。ネット上で提供されている方法は以下の通りです。
Google Chrome のショートカットの場所で
ターゲットに以下を追加します:
"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" -args --disable-web-security --user-data-dir --allow-file-access-from-files
大体こんな感じですが、私はこの方法をあまりお勧めしません。なぜなら、この方法は特に優雅な解決策ではないからです。
別の試み#
別の解決策は、ローカルに nginx をデプロイすることです。上で述べた解決策のように、file プロトコルを使用せずにファイルを開くことができます。
file プロトコルの開き方とは何ですか?
大体こんな感じです。
http 形式で開く方法に変更する必要があります。例えば、以下のように。
また、面倒でなければ、ウェブページをサーバーにデプロイすることも一つの解決策です。
意外な結果#
しかし!!最終的に問題は解決されませんでした!これは私を困らせました。。
事には理由があるもので、1 時間以上の努力の末、私は問題の根源を見つけました。
————
ajax のリクエスト URL は http 形式でなければならない。。
あああああ、やはり私はまだ未熟でした。。
AJAX を学び始めたばかりで、その原理に不慣れだったため、最終的にこのような問題が発生しました。