API Key認証付きのAPI GatewayからS3へリダイレクトしたい(できない)

こんにちは、AWS担当のwakです。

概要

表題の通りです。できない理由を書きます。また、ついでにSame-Origin PolicyとCORS (Cross-Origin Resource Sharing)についても簡単に解説します。

f:id:nurenezumi:20170310124638j:plain

猫もおてあげ

やりたかったこと

とあるデータ(刻々と追加されていきます。更新はなし)をDynamoDBに溜め込んでいます。そのデータを検索して返すWeb APIAPI Gateway + Lambdaで実装しました。

ところで、これらのデータは1日単位でS3にJSON形式で書き出してアーカイブとして保存してあります。もし検索条件が日付だけなら、わざわざDynamoDBを見るまでのこともありません。「S3のこのURLを見てね」と言えばいいだけです。つまり、こういうことができると都合がいいわけです。

  1. API Gateway経由でLambdaを起動する
  2. 条件が複雑な場合は普通にDynamoDBを検索して返す
  3. 条件が日付だけの場合はHTTP 303 See OtherでS3にリダイレクトする

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でアクセスし、取得したデータを全て外部に送信してしまいます。
  • 私は死にます。
f:id:nurenezumi:20170310123858p:plain

このようなことではいけません(私は死にたくありません)。もちろんこの秘密のデータを他のWebサイトから参照したいような状況もありますが、少なくとも全ての外部Webサイトのスクリプトからアクセスできる必要はまったくありません。そこで、スクリプトは原則として「同一のオリジン(場所)」にあるリソースにしかアクセスできないというルールができました。これが同一オリジンポリシーです。

オリジンとは

オリジンという言葉が突然出てきましたが、これは大ざっぱに言えばドメインのことだと理解すればいいです。厳密にはRFC 6454で定義されていて、

  • HTTP/HTTPSの区分
  • FQDNexample.comとか、www.example.comとか)
  • ポート番号

をセットにしたものです*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を利用します。

f:id:nurenezumi:20170224071730p:plain

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でのアクセス制御がかけられないことを意味していて、重要なデータを扱うには非現実的になります。

cors - Are there any browsers that set the origin header to "null" for privacy-sensitive contexts? - Stack Overflow

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.

つまり、このような処理が行われます。

  1. 最初にブラウザはプリフライトリクエストとしてOPTIONSリクエストを送信します。
  2. API GatewayはHTTP 200 OKを返します。レスポンスにはAccess-Control-Allow-Originも含まれます。
  3. 次にブラウザはGETリクエストを送信します。
  4. API GatewayはLambdaを実行し、上記のコードに従ってHTTP 303 See Otherを返します。
  5. ブラウザは無条件で通信をキャンセルします。

API GatewayAPI Key認証を行うためにはリクエスト時にX-Api-Keyヘッダを含める必要があります。このヘッダを含めた場合、リクエストは「シンプルなクロスオリジンリクエスト」ではなくなってしまいます。するとCではなく)のパターンになり、ブラウザは仕様上通信を行うことができません。

結論

お疲れ様でした。

*1:同僚のSlackのアカウントを乗っ取って「これを見てください」と言ってURLを送るとか

*2:データURIなどについても記述がありますが省略します

*3:本当は jquery.get() で書けますが比較のために jquery.ajax() で書いています

*4:OPTIONSリクエストですからボディ部はありません

*5:「Enable CORS」の設定を行ったときに追加されたOPTIONSメソッドの設定に従ってレスポンスが送信されます

*6:"*"はデフォルト値です。もちろん変更も可能です

*7:Chrome 56.0.2924.87, Firefox 51.0.1で確認

*8:ただし「リダイレクトのときはこうなる」と明示されているわけではなく、ブラウザの実装に依存します