CORSとSOP
CORS(オリジン間リソース共有:Cross-Origin Resource Sharing)を理解するためには、まずSOP(同一オリジンポリシー:Same-Origin Policy)を理解する必要があります。
本記事では、SOPとCORSの概要を解説した後、CORSの検証環境を構築し、CORSを実際のコードと画面を用いて理解を深めていきます。
SOPとは
ある2つのWebリソースのOriginが一致しているとき、それらはSame-Originであるといいます。一方、2つのWebリソースのOriginが異なるときはCross-Originであるといいます。そして、「Cross-Originなリソースへのブラウザ内アクセスの禁止と、Cross-Originなリソースへのネットワーク越しのアクセスの部分的な禁止を行うセキュリティ機構のことをSOP (Same-Origin Policy) といいます。
Webブラウザセキュリティ Webアプリケーションの安全性を支える仕組みを整理する
ここでいうオリジンとは、基本的には以下の3つの組として定義される値です。
- スキーム(プロトコル)
- ホスト名
- ポート番号
言ってしまうと、SOPはブラウザのセキュリティ機構の一つで、オリジンの異なるリソースへのJavaScriptでのアクセスを禁止する機能を指します。
例えばSOPが実装されていない場合、悪意のあるWebサイトにアクセスしただけで、ログイン中のサイトのデータが取得されてしまうなどの被害にあう可能性があります(別オリジンのリソースにJavaScriptでアクセスされてしまうことによる漏洩)。
CORSとは
上記のように、ブラウザは異なるオリジン同士でリソースを共有することができないようになっています。
例えば、天気予報のデータを集めて公開しているWebサイトがあったとして、利用者からの要望によってその情報をAPIとしてJSONで公開したとします。ただし、前述のように別オリジンからそのAPIのリソースを取得してもブラウザに出力することはできません。ブラウザはSOPに従い別オリジンのリソースにアクセスできないからです。
でもAPIって普通にデータ使えるよね
ここで疑問に思う方もいるかもしれませんが、今どき別ドメイン(オリジン)からAPIでデータを取得して利用するという行為は当たり前に行われています。
つまり、ブラウザにはセキュリティを考慮しつつSOPの制限を回避する仕組みが用意されているということです。その仕組みをCORSと言います。
CORSでは主にOriginとAccess-Control-Allow-Originという2つのHTTPヘッダを用いて、Cross-Originな関係にあるリソースがブラウザ内でアクセスされることを許可します。つまり、リクエスト側とレスポンス側がリソースを共有することを明示的に宣言していれば、ブラウザも認めてくれるということです。
実際に手を動かして確認した方が理解が早いと思うので、まずはCORSの検証環境を作成します。特に興味ない方は環境構築方法は飛ばしてください。
CORSの検証環境構築
CORSの検証なので、オリジンが異なる2つのWebサイトを用意する必要があります。
ここではDocker上のApacheのVirtualHost機能を利用して、2つのオリジンを用意します。
なお、DockerへのApacheコンテナの建て方は以下の記事を参考にしてください。
VirautlBox(Ubuntu)のDockerにWebサーバを構築してディレクトリを共有するVirtualHostの設定
Docker上のApacheにVirtualHostの設定を行います。
<VirtualHost *:80>
DocumentRoot /var/www/html/example.com
ServerName example.com
</VirtualHost>
<VirtualHost *:80>
DocumentRoot /var/www/html/example.jp
ServerName example.jp
</VirtualHost>
PCのhostsの設定
Windowsのhostsを以下のように編集し、それぞれにアクセス可能かを確認します。
なお、公開ファイルが存在しないので”403 Forbidden”になると思いますが、それで問題ありません。
127.0.0.1 example.com example.jp
ここではホストOSのhostsを編集した結果、example.comとexample.jpは両方とも127.0.0.1:80宛に送信されることになります。
同じIPでも適切なレスポンスが返るのは、HOSTヘッダによってWebサーバがホストを区別しているからです。
また、CDNを利用する場合はドメイン名に紐づくIPがCDNのIPアドレスになるので、オリジンサーバの特定にHOSTヘッダが使われます。
ネットワークレベルで名前解決は行われているのに態々HOSTヘッダが存在するのはそのような理由からです。
CORS検証環境を使って色々見てみる
CORSの検証環境が構築できたので、Cross-Originでのリクエストを試してみます。
Cross-Originでのajaxライブラリによるアクセス
以下の2つのファイルを用意して、それぞれexample.comとexample.jpに配置しました。本記事ではexample.jpがリソースを提供する側です。
- example.com:index.html
- example.jp:json.php
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>example.com</title>
</head>
<body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.jp/json.php');
xhr.onreadystatechange = function() {
if(xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
}
xhr.send();
</script>
</body>
</html>
<?php
header('Content-Type: application/json');
echo json_encode(['status' => 'OK']);
example.comにアクセスすると、以下のようにエラーが発生します。
クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、http://example.jp/json.php にあるリモートリソースの読み込みは拒否されます (理由: CORS ヘッダー ‘Access-Control-Allow-Origin’ が足りない)。
SOPによってCross-Originのアクセスが制限されていることが分かります。
CORSアクセスを許可する
CORSを許可する方法は、基本的には以下のように「Access-Control-Allow-Origin」ヘッダをレスポンスに追加するだけです。
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: http://example.com');
echo json_encode(['status' => 'OK']);
なお、リクエスト側のOriginヘッダはCORSリクエストとPOSTリクエストの場合に自動で送信されます。
CORSエラーはブラウザに出力しないだけ
あるオリジンで動作しているウェブアプリケーションに、異なるオリジンにある選択されたリソースへのアクセス権を与えるようブラウザーに指示するための仕組みです。
オリジン間リソース共有 (CORS) | developer.mozilla.org
上記に記載されているように、CORSで許可しているのはブラウザへのアクセス権であるため、実はレスポンスボディにデータが存在します。
下図はAccess-Control-Allow-Originを設定していない(CORSエラーになる)状態でアクセスした際のリクエストとレスポンスです。
ブラウザではCORSエラーとなり、リソースへのアクセス(本記事では単なる出力)は失敗しますが、上図のようにリソース自体はレスポンスされます。
同一オリジンへのアクセスにおけるヘッダ
同一オリジンのJSONを取得する場合、Access-Control-Allow-Originが無くても、ブラウザには勿論アクセス許可があります。
当たり前のことですが、理解が進むと当たり前すぎて頭から抜けることがあります。SOPで制限されるのはあくまでCross-Originリクエストです。
認証情報の追加
ajaxライブラリによるCross-Originなリクエストには、原則としてCookieが付与されません。
レスポンス側のコードで、以下のようにセッション管理を行うように修正した後にexample.comにアクセスしてみます。
<?php
session_start();
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: http://example.com');
if(empty($_SESSION['counter'])) {
$_SESSION['counter'] = 1;
} else {
$_SESSION['counter']++;
}
echo json_encode(['count' => $_SESSION['counter']]);
上記のように、一見セッション変数内の値がレスポンスされているように見えますが、ページを更新してもカウンタの値が増えません。
これはXMLHttpRequestのリクエスト時にCookieが付与されていないからです。
Cookieが無い為、レスポンスボディも毎回1が返ってきます。
Cookieを送信するようにする
しかし、外部APIへのアクセスではCookie中のセッションIDなどの認証情報が必要になる場合があります。
そのような状況に対応できるよう、認証情報(CookieやBASIC認証用のヘッダ)をCORSリクエストに含める方法があります。
実際に認証情報を付加したリクエストの送り方を、コードを修正して確認してみます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>example.com</title>
</head>
<body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.jp/json.php');
xhr.withCredentials = true;
xhr.onreadystatechange = function() {
if(xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
}
xhr.send();
</script>
</body>
</html>
「xhr.withCredentials = true;」を追加した結果、レスポンスのヘッダにCookieが追加されます。
上記のように、Cookieが追加されたことでセッション変数の内容が更新されていますが、下図のようにリソースへのアクセスには失敗します。
クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、http://example.jp/json.php にあるリモートリソースの読み込みは拒否されます (理由: CORS ヘッダー ‘Access-Control-Allow-Credentials’ は ‘true’ であるべき)。
「withCredentials = true」を追加することでリクエスト時にCookieを送信するようになりますが、レスポンスでもそのCookieを使うことを明示しなければなりません。
<?php
session_start();
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: http://example.com');
header('Access-Control-Allow-Credentials: true');
if(empty($_SESSION['counter'])) {
$_SESSION['counter'] = 1;
} else {
$_SESSION['counter']++;
}
echo json_encode(['count' => $_SESSION['counter']]);
「Access-Control-Allow-Credentials: true」を追加することで、下図のように送信されたCookieが処理に利用されるようになります。
Cookieを送信できない場合
Access-Control-Allow-Originのディレクティブに*を指定した場合、Cookieは送信されません。
Access-Control-Allow-Credentialsを定義する場合、Access-Control-Allow-Originのディレクティブは必ずオリジンを指定する必要があります。
<?php
session_start();
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');
if(empty($_SESSION['counter'])) {
$_SESSION['counter'] = 1;
} else {
$_SESSION['counter']++;
}
echo json_encode(['count' => $_SESSION['counter']]);
クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、http://example.jp/json.php にあるリモートリソースの読み込みは拒否されます (理由: CORS ヘッダー ‘Access-Control-Allow-Origin’ が ‘*’ である場合、認証情報はサポートされない)。
同一オリジンへの認証情報の付加
同一オリジンへのajaxライブラリによるアクセス時には、特に何もしなくても認証情報は付加されます。
以下のようにリクエストの「withCredentials = true」とレスポンスの「Access-Control-Allow-Credentials: true」をコメントアウトしても、Cookieの送信と利用には何ら影響しません。
また、同一オリジンの為「Access-Control-Allow-Origin」も当然ながら不要です。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>example.com</title>
</head>
<body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/json.php');
//xhr.open('GET', 'http://example.jp/json.php');
//xhr.withCredentials = true;
xhr.onreadystatechange = function() {
if(xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
}
xhr.send();
</script>
</body>
</html>
<?php
session_start();
header('Content-Type: application/json');
//header('Access-Control-Allow-Origin: http://example.com');
if(empty($_SESSION['counter'])) {
$_SESSION['counter'] = 1;
} else {
$_SESSION['counter']++;
}
echo json_encode(['count' => $_SESSION['counter']]);
単純でないCross-Originリクエスト
ここまで紹介したものは、全て「単純リクエスト」や「シンプルなリクエスト」と呼ばれるものです。
単純リクエストであれば、SOPのもとでもCross-Originなリクエストの発行は許可されます。単純リクエストとは以下の条件をすべて満たすものです。
- GET
- HEAD
- POST
- Accept
- Accept-Language
- Content-Language
- Content-Type(但し、下記の要件を満たすもの)
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
上記を1つでも満たしていないリクエストは、SOPのもとでは原則として禁止されているリクエストです。
逆になぜ上記だけが許可されているかというと、HTMLフォームで送信できる範囲だからです。
これらは、HTMLフォームから送られるリクエストを基準として、HTMLフォームの場合に比べて過度にリスクが増加しない範囲で条件が選択されています。HTMLフォームはもともと異なるサイト(オリジン)に対してリクエストを無条件に送信できるため、HTMLフォームで送れる程度に制限しておけば、XMLHttpRequestでクロスオリジンにリクエストを送信しても、リスクはそれほど変わらないと考えられるからです。
体系的に学ぶ 安全なWebアプリケーションの作り方 第2版
ただし、上記の条件から外れた「単純でないCross-Originリクエスト」を許可する方法もあります。
「プリフライトリクエスト」といって、リクエストの送信前に特定のヘッダを付加したOPTIONSメソッドを事前に送信し、許可された場合のみリクエストを送信する方法です。
プリフライトリクエスト
単純でないCross-Originリクエストの許可方法の前に、実際に単純でないCross-Originリクエストを送ってみて、プリフライトリクエストが飛ぶ様子と許可されなかった場合の動きを見てみます。
ここではリクエストのcontent-typeをapplication-jsonにしています。単純リクエストで定義されているContent-Typeではない為、単純でないCross-Originリクエストになります。
なお、content-typeはレスポンスにおいてはクライアントに返されたコンテンツの実際の種類を、リクエストにおいてはクライアントがサーバに送ったデータの種類を伝えるヘッダです。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>example.com</title>
</head>
<body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.jp/json.php');
xhr.setRequestHeader('content-type', 'application-json');
xhr.onreadystatechange = function() {
if(xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
}
xhr.send();
</script>
</body>
</html>
上記の状態でexample.comにアクセスした場合、OPTIONSメソッドのプリフライトリクエストが送信されていることが分かります。ただし、その先のリクエストがありません。
また、プリフライトリクエストには以下のヘッダが自動で追加されていることが確認できます。
Access-Control-Request-Method: GET
Access-Control-Request-Headers: content-type
ちなみに、Content-Typeを変えずにメソッドだけ”DELETE”などに変更するとAccess-Control-Request-Methodだけが追加されます。プリフライトリクエストに必ず両方が存在するわけではありません
ブラウザでは当然エラーが発生しています。単純でないCross-Originリクエストを許可するためのヘッダが不足しているためです。
ヘッダを許可する
上記のように、Content-Typeを変更した結果単純でないCross-Originリクエストになった場合、レスポンスヘッダに「Access-Control-Allow-Headers: content-type」が存在した場合、アクセスが許可されます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>example.com</title>
</head>
<body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.jp/json.php');
xhr.setRequestHeader('content-type', 'application-json');
xhr.onreadystatechange = function() {
if(xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
}
xhr.send();
</script>
</body>
</html>
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: content-type');
echo json_encode(['status' => 'OK']);
メソッドを許可する
単純リクエストのメソッドの要件はGET/HEAD/POSTでした。
つまりそれ以外のメソッドでCORSリクエストを送る場合、単純でないCross-Originリクエストとなり、追加のヘッダが必要になります。
追加するヘッダは「Access-Control-Request-Method: メソッド」です。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>example.com</title>
</head>
<body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('DELETE', 'http://example.jp/json.php');
xhr.onreadystatechange = function() {
if(xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
}
xhr.send();
</script>
</body>
</html>
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: DELETE');
echo json_encode(['status' => 'OK']);
メソッドとヘッダの両方が単純リクエストの条件から外れた場合、Access-Control-Allow-MethodsとAccess-Control-Allow-Headersの両方をレスポンスに含める必要があります。
直接JSONにアクセスした場合
JSONに直接アクセスした場合、単純リクエストも単純でないリクエストもJSONが返ってきます。
何度も記載しているように、CORSは当該ファイルそのものへのアクセス権を指定しているわけではなく、ajaxライブラリによるアクセス時にレスポンスされたリソースを当該オリジンで利用できるかを定義しているものなので、JSONへの直接アクセス自体には影響しません。
特定のユーザにのみJSONを返したい場合は、上述した方法を使ってCookie等を使った認証処理をサーバサイドに追加してください。
「Access-Control-Allow-Origin: * 」は脆弱なのか
認証情報を付与する以外のパターンのCORSアクセスにおいて、Access-Control-Allow-Origin: * とすることは許容されます。
しかし、基本的には以下のようにオリジンを明示することが推奨されます。
ここで注意してほしいのは、CORSのヘッダをAccess-Control-Allow-Origin: *のような形で指定すれば、それだけ大きなリスクを生むということです。CORSは、あくまでもSOPで守られるはずのCross-Originな操作をわざわざ許可するための仕組みなのですから、これは当然とも言えます。
Webブラウザセキュリティ Webアプリケーションの安全性を支える仕組みを整理する
CORSではオリジンを指定する際に、以下のような形で、どのオリジンでもよいという指定ができます。
Access-Control-Allow-Origin: *
公開情報を提供するなどでオリジンの制限がない場合はこれでよいのですが、非公開情報を扱う場合は、* 指定だと情報漏洩の危険性があります。オリジンの制限がある場合は原則としてHTTPリクエストのOriginヘッダを検証した上で、レスポンスヘッダとして以下のようにオリジンを明示します。オリジンは一例です。ただし、連携先が非常に多いような場合、敢えてオリジンとして * を記述し、他の方法で情報漏洩を防ぐ実装もあります。
Access-Control-Allow-Origin: https://example.jp
体系的に学ぶ 安全なWebアプリケーションの作り方 第2版
「認証情報が必要な場合は*を指定できないし、レスポンスボディを見たり直接アクセスしたりするとデータが確認できるから*でええやん」という考えもあるかもしれませんが、不要なアクセス権を与える必要は無いし、いつかどこかでセキュリティ事故が発生する可能性も否定できません。
加えて、内容が見られるのは問題ないがリソースとして利用されると困るようなものもあります。
例えば近隣のスーパーの商品価格を毎日データ化して、特定のユーザにのみAPIとして提供していた場合、そのデータが第3者に見られること自体は問題ありませんが、リソースとして勝手に利用されると問題があります。
そのような場合でもやはり、Access-Control-Allow-Originできちんとオリジンを明示しておけばリソースが勝手に使われることはありません。
まとめ
異なるオリジンに対してリソースへのアクセス権を与えるようブラウザに指示する場合、以下に示すヘッダが必要です。
リクエストヘッダ | レスポンスヘッダ | |
---|---|---|
単純リクエスト | Origin(ブラウザが自動で付与) | Access-Control-Allow-Origin: オリジン or * |
単純リクエストかつ認証情報が必要なアクセス | Origin(ブラウザが自動で付与) withCredentials = true(ヘッダではなくライブラリへの指示)(書き方はライブラリによる) | Access-Control-Allow-Origin: オリジン Access-Control-Allow-Credentials: true |
単純でないCross-Originリクエスト | Origin(ブラウザが自動で付与) Access-Control-Request-Method: メソッド (ブラウザが自動で付与) Access-Control-Request-Headers: ヘッダ (ブラウザが自動で付与) | Access-Control-Allow-Origin: オリジン or * ○メソッドがGET/HEAD/POST以外 Access-Control-Allow-Methods: メソッド ○ヘッダが定義されたもの以外 Access-Control-Allow-Headers: リクエストヘッダ名 |