CloudFront + S3で配信しているSPAのサイトをCross-Origin Isolationに対応しました

こんにちは、SREグループの浜地です。 GO株式会社では、様々な社内/社外向け管理画面システムをホスティングしており、多くはAmazon S3Amazon CloudFrontを利用したSingle Page Application(以降SPA)構成をとっています。 今回はそのうちのひとつでCross-Origin Isolation(クロスオリジン分離)の環境を作る必要があったため、背景を含めて今回採用した対応方法を紹介しようと思います。

背景

すでにローンチしていたSPAのシステムの開発担当者から、「このサイトをCross-Origin Isolationに対応させたい」と依頼がありました。 話をよく聞いてみると、とある外部SDKを利用しようとしているが、Cross-Origin Isolationではない環境下でこのSDKを使うと機能制限を受けてしまい、十分に使いこなせないとのことでした。 機能制限下のままでは運用が大変になるという話になっていたため、Cross-Origin Isolation化の対応方法を調べ、作業することになりました。

Cross-Origin Isolationとは

少々長くなりますが、まずCross-Origin Isolationという概念が生まれた経緯を説明します。

元々ウェブブラウザでは、SharedArrayBufferというスレッド間で共有可能な物理メモリ領域を確保するためのAPIが提供されていました。

ところが、2018年1月にSpectreというハードウェアレベルの脆弱性が報告されました。この脆弱性によってあらゆるプロセスで実行されているメモリ領域の値を投機的実行によって予測できる状態になってしまいました。当時システムを運用していた人にとっては非常に大きな事件だったと記憶されている方も多いでしょう。

その後、OS側で対応されたことで同一プロセス内のみの影響となり、一旦の落ち着きを見せた(ただし、パフォーマンスが著しく劣化したことでさらなる対応に迫られた方もいると思います・・・)ものの、ウェブブラウザではさらなる対応が必要でした。

例えばGoogle Chromeでは、タブごとにプロセスを分離するモデルになっていたのですが、iframeやポップアップで開くウェブサイトの処理は同一プロセス内で実行される仕組みになっていました。 そのため、攻撃サイトがiframe内で任意のサイトを呼び出すことでその任意サイトの各リソース(Cookieなど)を投機的実行によって予測できる状態になってしまいました。 とくに、「高解像度で時間を測れるAPI」があるとその予測成功率が高くなることから、各ブラウザベンダーではSharedArrayBufferを始めとした「高解像度で時間を測れるAPI」の利用制限に踏み切りました。

注:SharedArrayBufferは高解像度で時間を測れるAPIそのものではありませんが、工夫することによって「高解像度で時間を測れるAPI」として機能させられることから、制限の対象になりました。

このSharedArrayBufferは便利なクラスだったため、多くのウェブサイトならびにライブラリで利用されていたようです。そのため、これを利用するためにブラウザの「SharedArrayBufferの有効化」フラグを操作する人も多かったのではないかと予想しています。

時系列が若干前後しますが、Spectre脆弱性報告前からGoogleのプロジェクトチームはSite Isolation機能の実現について模索しており、タイミングよく(?)Spectre脆弱性が報告されたことで実現の機運が高まったようです。Spectre脆弱性報告後の2018年7月にGoogle ChromeでSite Isolation機能がリリースされましたが、デスクトップPC版Chromeでのみ利用でき、モバイルデバイスでは使えないという状態でした。おそらくCPUアーキテクチャに依存した実装だったのではないかと思われます。 その後、Web標準化の関係者が集まりCross-Origin Isolationについて仕様策定され、Google Chromeは2021年にリリースした92から(Firefoxも対応済みですが、どのバージョンかはわかりませんでした)Cross-Origin Isolationに対応されました。 Cross-Origin Isolationを実現したWebサイトであれば再びSharedArrayBufferや「高解像度で時間を測れるAPI」を有効化すると宣言され、条件を満たすことで再び利用可能な状態になりました。


経緯が長くなってしまいましたが、Cross-Origin Isolationとは外部のオリジンから完全に切り離すことを指し、これを満たしたウェブサイトではSharedArrayBufferを始めとしたAPIを利用することができるようになります。

目的のウェブサイトをCross-Origin Isolation化し、正常に利用するためには以下の対応が必要です。今回はSPAのウェブサイトを扱うため、その前提の対応内容を記載します。

  1. SPAのページがレスポンスヘッダ Cross-Origin-Opener-Policy: same-origin を返している(COOPと呼びます)
  2. SPAのページがレスポンスヘッダ Cross-Origin-Embedder-Policy: require-corp を返している(COEPと呼びます)
  3. SPAのページからアクセスされるリソースがレスポンスヘッダ Cross-Origin-Resource-Policy (CORPと呼びます)もしくはCORS用のレスポンスヘッダを返している

1,2 を満たしていればSPAはCross-Origin Isolation状態と見なされますが、3を満たしていない場合このリソースへのアクセスは分離されていないクロスオリジンへのアクセスと見なされます。 そのためリクエストがブロックされてしまい、ウェブサイトを正常に利用することはできません。

Cross-Origin Isolation 化の対応内容

Cross-Origin Isolation化前の構成は図のような状態でした。

APIサーバは別オリジンのため、すでにCORSのヘッダを返す設定が入っていたので、APIサーバの対応は今回不要でした(上述 3 を満たしている状態)。 よって、今回のCross-Origin Isolation化では当初以下の対応が必要だと考えました。

  • SPAをホスティングしているCloudFront(admin.example.com)にResponse Headers Policyなどを利用して、index.htmlの内容を返すレスポンスにヘッダ Cross-Origin-Opener-Policy: same-origin および Cross-Origin-Embedder-Policy: require-corp を付与する
  • 画像を配信しているCloudFront(images.example.com)にResponse Headers Policyなどを利用して、すべて正常レスポンスにヘッダ Cross-Origin-Resource-Policy: cross-origin を付与する

Cross-Origin Isolation化しない場合、クロスオリジンの画像URLでもimgタグを使って表示する場合はCORSの設定は不要ですが、Cross-Origin Isolation化するときはCORPヘッダまたはCORS用のヘッダの返却が必要になります。

この実現にあたって、CloudFrontのResponse Headers Policyを使うことで、ほとんど工数を割かずに対応できるものとして考えていましたが、後述の通り2点の問題が発生したため、対応方法を少し変えることになります。

問題その1:SPAのCloudFrontでResponse Headers Policyやエッジ関数が動作しない

構成図の通り、SPAのindex.htmlを返すためにCloudFrontでError Pageを利用し、S3から返ってきたHTTPステータス403を200に上書きしたうえでindex.htmlを返す構成にしていました。 これはCloudFront + S3でSPAをホスティングする場合、よく取られる手段ではあります。 ただし、「CloudFrontではオリジンから4xxが返ってきたとき、Response Headers PolicyやViewer Response Eventに設定したエッジ関数が動作しない」という仕様がありました。

そのため、SPAをホスティングしているCloudFrontにResponse Headers Policyを設定したもののレスポンスヘッダが付与されない事象に遭遇しました。 Response Headers Policyの代わりにViewer Response EventにCloudFront Functionsを設定してヘッダ付与を試みましたが、期待する結果を得られない状態でした。

Origin Response Eventへの設定は試していませんでしたが、調べてみるとキャッシュの状況によって多少挙動が変わるものの、おそらく期待する動作になっていた可能性があります。 ただ、Response Headers Policyが動いていないことの疑問を解消したいことを優先的に考えたため、Origin Response Eventへのエッジ関数設定は考えていませんでした。

AWSサポートに相談したところ、「CloudFrontの(Responseではなく)Request Event Triggerに関数を設定しRequest URI/index.html に上書きすることで、S3オリジンから403ではなく200が返ってくるようになりResponse Headers Policyやエッジ関数が動作するようになる」と助言をいただきました。 CloudFrontのRequest EventはViewer RequestとOrigin Requestの2種類ありますが、Cross-Origin Isolation化の場合は基本的にCloudFront Functionsを使うのが良いと思われます。 Lambda@EdgeはCloudFront Functionsよりも呼び出し数制限が厳しいため、秒間1万件を超える大量のアクセスが見込まれるウェブサイトでLambda@Edgeを使うと不具合が発生するおそれがあります。

ただ、すでにこのSPAでは別のCloudFront Functionsが設定されており、それがインフラの共通モジュールによって設定されているものだったことと、その関数の変更に時間がかかってしまうことが予想されました。 想定リクエスト数も制限を超えるようなものではなかったため、今回はOrigin Request EventにLambda@Edgeを設定することで対応しました。 関数は数行に収まるとても簡単なものです。

'use strict';
exports.handler = (event, context, callback) => {
  const request = event.Records[0].cf.request;
  
  request.uri = '/index.html';
  return callback(null, request);
}

問題その2:全ページに適用すると別のページで機能不具合が発生

ウェブサイト全体にCross-Origin Isolation化用のレスポンスヘッダを付与したものの、Cross-Origin Isolation化したいページ以外で一部のリソースリクエストがブロックされる事象が発生しました。 これはそのページで別のクロスオリジンのリソースを呼び出していることが原因で発生するものでした。 このクロスオリジンリソースが当社で管理しているものならこのリソースにもレスポンスヘッダを付与すればよいのですが、他社管理のリソースかつ設定変更不可のリソースでした。 幸いにも、今回導入する外部SDKを呼び出すページが限定的であったため、ウェブサイト全体に適用する方針から特定のページのみをCross-Origin Isolation化する方針に変更することになりました。

この条件分岐はCloudFrontのBehaviorを新たに宣言することによって実現することにしました。

改めて対応内容と対応後の構成

2つの問題を経て、最終的な要件と対応内容はこうなりました。

  • 要件
    1. admin.example.comの特定のpath(/using-sdk とします)へのアクセス時にのみCOOPおよびCOEPヘッダを付与
    2. images.example.comのすべての正常レスポンスにCORPヘッダを付与
  • 対応内容
    1. admin.example.com用のCloudFrontにおいて、Cross-Origin Isolation化したいURLを新規CloudFront Behaviorとして作成する
    2. 上述のBehaviorのOrigin Requestイベント※のトリガーにRequest URI/index.html に上書きするLambda@Edgeの関数を設定する
    3. 上述のBehaviorにResponse Headers Policyを設定し、レスポンスヘッダに Cross-Origin-Opener-Policy: same-origin および Cross-Origin-Embedder-Policy: require-corp を付与する
    4. images.example.comのCloudFrontにResponse Headers Policyを設定し、レスポンスヘッダに Cross-Origin-Resource-Policy: corss-origin を付与する

※前述の通り、本当はViewer RequestイベントをトリガーにしてCloudFront Functionを呼び出すほうがよいです。

これらの対応を実施したことで無事Cross-Origin Isolation状態とみなされ、外部SDKの機能制限が撤廃されました。

この対応をした結果、以下のような構成になりました。

今回の対応ではdefault behaviorには変更を加えなかったため、特定のURL以外では引き続きError Pageを利用してindex.htmlを返しています。 アセットファイル等のパスを新たにBehaviorとして追加し、Error Pageを辞めてCloudFront FunctionsまたはLambda@Edgeを利用してRequest URIを上書きする方法で統一してもいいかもしれません。

謝辞&参考文献

対応中に発生した問題についてAWSサポートに相談させていただいたところ、親身に解決方法についてご教示くださりました。この度は誠にありがとうございました。

また、本対応および本記事執筆にあたって、以下のページを参考にさせていただきました。誠にありがとうございました。

Spectre の脅威とウェブサイトが設定すべきヘッダーについて - Tender Surrender Chrome の Site Isolation で Spectre のリスクを軽減する - Google Developers Post-Spectre Web Development - W3C standards and drafts SharedArrayBuffer と過渡期な cross-origin isolation の話 - Tender Surrender