こんにちは、AWS担当のwakです。
概要
表題の通りです。できない理由を書きます。また、ついでにSame-Origin PolicyとCORS (Cross-Origin Resource Sharing)についても簡単に解説します。
猫もおてあげ
やりたかったこと
とあるデータ(刻々と追加されていきます。更新はなし)をDynamoDBに溜め込んでいます。そのデータを検索して返すWeb APIをAPI Gateway + Lambdaで実装しました。
ところで、これらのデータは1日単位でS3にJSON形式で書き出してアーカイブとして保存してあります。もし検索条件が日付だけなら、わざわざDynamoDBを見るまでのこともありません。「S3のこのURLを見てね」と言えばいいだけです。つまり、こういうことができると都合がいいわけです。
3番目は無理でした、というのが今回の記事の趣旨になります。以下、その説明をします。
Same-Origin Policy
ブラウザには、RFC 6454で定められたSame-Origin Policy(同一オリジンポリシー、同一生成源ポリシー)というルールがあります。ここでは「オリジン」が何かはちょっと後回しにして、次のようなシナリオを考えてみます。
mycompany.local
という社内Webサーバーがあるとします。このWebサーバーは外部には公開されていません。- 私はここなら安心だと思って、秘密のデータを
http://mycompany.local/himitsu.json
として置いておきます。 - 悪い人がいて、どこからかこのURLを入手したとします。
- 悪い人は
http://evil.com/dorobou.html
というWebページをインターネット上に作り、何らかの方法*1で私にアクセスさせます。 - このWebページにはJavaScriptが書いてあります。スクリプトはWebページを開くと同時に勝手に上記のURLにXHRでアクセスし、取得したデータを全て外部に送信してしまいます。
- 私は死にます。
このようなことではいけません(私は死にたくありません)。もちろんこの秘密のデータを他のWebサイトから参照したいような状況もありますが、少なくとも全ての外部Webサイトのスクリプトからアクセスできる必要はまったくありません。そこで、スクリプトは原則として「同一のオリジン(場所)」にあるリソースにしかアクセスできないというルールができました。これが同一オリジンポリシーです。
オリジンとは
オリジンという言葉が突然出てきましたが、これは大ざっぱに言えばドメインのことだと理解すればいいです。厳密にはRFC 6454で定義されていて、
をセットにしたものです*2。つまりhttp://evil.com
上にあるスクリプトは、次のような場所にあるリソースにはアクセスできないわけです。
http://mycompany.local
(ドメインが全然違う)https://evil.com
(同じに見えるけどHTTPとHTTPSが違う)http://evil.com:8080
(ポート番号が80番と8080番で違う)http://www.evil.com
(これも別扱い)
これだけ厳密なチェックが入るなら安心ですね。めでたしめでたし。
CORS (Cross-Origin Resource Sharing)
ところが、異なるオリジンをまたいで外部リソースを参照することが一切許されないのでは不都合なことも多々出てきます。そこで、リソースが置いてある側、つまりmycompany.local
側で事前に設定を行い、どのようなタイプのリクエストなら許可するかをホワイトリスト形式で指定することになりました。これがCORS (Cross-Origin Resource Sharing)です。
具体的には、ブラウザからのリクエストに応じ、リソースを置いてあるサーバーがHTTPヘッダで「どこからのアクセスなら許可するか」「リクエスト時に含まれていても良いHTTPヘッダ」のような項目を回答します。ブラウザはこの値をチェックし、リクエストの内容がそれとマッチするときにだけアクセスを許可します(そうでないならアクセスをブロックしてネットワークエラーを発生させます)。この制御により、リソースの所有者の意図、ブラウザの利用者の意図に反してアクセスが行われてしまうことを防ぐわけです。
余談:CORSは何でないか
CROSはあくまでブラウザが(いわば)自主規制をするだけのもので、ユーザーが意図的に発生させたリクエストをブロックするようなものではありません。悪意を持って作成されたスクリプトを実行してしまっても、リソースの所有者やユーザーが意図しないうちに勝手にリクエストが発生してしまう(発生させられてしまう)ことを防ぐための仕組みです。セキュリティについてはサーバーサイドできちんと考える必要があります。
2種類のクロスオリジンリクエスト
このように、アクセス元のオリジン(上記の例ですとhttp://evil.com
)、リソースのあるオリジン(同じくhttp://mycompany.local
)とが異なる場合のアクセスはクロスオリジンリクエストと呼ばれます。ブラウザはこのリクエストを2種類に分けて扱います。
シンプルなクロスオリジンリクエスト
シンプルなクロスオリジンリクエスト(Simple cross-origin request)とは、特定のHTTPヘッダのみ・特定のContent-Typeの値のみを持つGET/POST/HEADリクエストです(詳しくはリンク先を読んでください)。jQueryでこのようなコードを書いた場合がこれに該当します*3。
$.ajax({ url: "https://...", type: "GET", headers: {} }).done(function(data) { console.log(data); });
スクリプトが「シンプルなクロスオリジンリクエスト」を送信した場合、ブラウザはまずレスポンスヘッダの中からAccess-Control-Allow-Origin
ヘッダの値を確認します。その値が元のオリジンと一致すれば(*
やhttps://*.example.com
などのワイルドカードも使用可)そのアクセスは認められることになります。逆に一致しないか、そもそもAccess-Control-Allow-Origin
ヘッダがなかった場合、そのアクセスはブロックされてしまいます。
シンプルではないリクエスト
前項のシンプルなクロスオリジンリクエストではないリクエストは全てこれです。認証のためにAuthorization
ヘッダやX-Api-Key
ヘッダを付加する場合、PATCHリクエストやDELETEリクエストを送信する場合などが該当します。たとえばBASIC認証が必要なURLにアクセスするため、jQueryで次のようなコードを書いたとします。
$.ajax({ url: "https://...", type: "GET", headers: {"Authorization": "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="} }).done(function(data) { console.log(data); });
この場合、ブラウザはGETリクエストに先立ち、同じURLに対して自動的にOPTIONSリクエストを送信します。これはプリフライト(preflight)と呼ばれ、スクリプト側からは制御できず強制的に行われます。クロスオリジンリクエストを受け付けたいWebサーバーは、このOPTIONSリクエストに対して「どのようなリクエストなら受け付けても構わないか」を返答しなければなりません。返答は全てレスポンスヘッダで行います*4。
API Gateway
以下、API GatwayからLambda (Node.js)のコードを呼び出し、そのまま結果を返すか、またはS3へリダイレクトすることを考えます。また、API Gateway側は「Enable CORS」の設定を済ませていて、さらにLambda Proxy integrationを使うことを前提にします。リクエストはGETを利用します。
A. シンプルなクロスオリジンリクエスト&リダイレクトを行わない場合
一番単純なパターンです。ブラウザが前述の「シンプルなクロスオリジンリクエスト」を送信してきて、かつリダイレクトを行わない場合です。Lambda側の処理はこうなります。
callback(null, { statusCode: 200, body: JSON.stringify({message: "nya-!"}), headers: { "Access-Control-Allow-Origin": "http://example.com", "Content-Type": "application/json; charset=utf-8" } });
ブラウザはここでセットしたAccess-Control-Allow-Origin
ヘッダの値を確認し、アクセス元のオリジンと一致するかどうかを調べて、問題がなければアクセスを許可します。API Gateway側のCORS設定は使用されません。
B. シンプルではないクロスオリジンリクエスト&リダイレクトを行わない場合
最初にブラウザがプリフライトリクエストを送ってきますが、これにはAPI Gatewayが勝手に応答します*5。レスポンスヘッダにはAccess-Control-Allow-Origin: *
という内容が含まれていて*6、ブラウザは最初のチェックを行います。
このチェックをパスしたら、ブラウザは改めてGETリクエストを送りますが、後の処理は前項(A)と同じです。再度Access-Control-Allow-Origin
のチェックがかかるところも変わりません。
C. シンプルなクロスオリジンリクエスト&リダイレクトを行う場合
可能ですが制約が厳しくなります。
callback(null, { statusCode: 303, // See Other body: "", headers: { "Access-Control-Allow-Origin": "http://example.com", Location: `https://s3-ap-northeast-1.amazonaws.com/BUCKET-NAME/path/name.json` } });
最初のアクセスに対してAPI Gatwayは303を返します。ブラウザはAccess-Control-Allow-Origin
をチェックした上でリダイレクト、つまり指定されたS3へ再度GETを送ります。
ところがこの2回目のGETでは、ブラウザはオリジンとしてnull
を使用します*7。この挙動はRFCで規定されています*8。したがって、リダイレクト先のS3側ではAccess-Control-Allow-Origin: *
を返す必要があります。CORSでのアクセス制御がかけられないことを意味していて、重要なデータを扱うには非現実的になります。
D. シンプルではないクロスオリジンリクエスト&リダイレクトを行う場合
ダメです。
Chrome cancels CORS XHR upon HTTP 302 redirect - Stack Overflow
こちらで紹介されているW3C Recommendationにはこのような記述があります。
If the response has an HTTP status code of 301, 302, 303, 307, or 308 Apply the cache and network error steps.
つまり、このような処理が行われます。
- 最初にブラウザはプリフライトリクエストとしてOPTIONSリクエストを送信します。
- API GatewayはHTTP 200 OKを返します。レスポンスには
Access-Control-Allow-Origin
も含まれます。 - 次にブラウザはGETリクエストを送信します。
- API GatewayはLambdaを実行し、上記のコードに従ってHTTP 303 See Otherを返します。
- ブラウザは無条件で通信をキャンセルします。
API GatewayでAPI Key認証を行うためにはリクエスト時にX-Api-Key
ヘッダを含める必要があります。このヘッダを含めた場合、リクエストは「シンプルなクロスオリジンリクエスト」ではなくなってしまいます。するとCではなく)のパターンになり、ブラウザは仕様上通信を行うことができません。
結論
お疲れ様でした。