
CloudFrontとWAFでフロントエンドとAPIを保護する
CloudFrontにWAFを設定してフロントとAPIを保護するというのをやってみました。画面としては以下のような画面で、ボタンをクリックするとユーザー情報を取得して一覧を作成する簡単なものです。
ユーザー情報は以下のAPIから取得しています。
https://jsonplaceholder.typicode.com/users

構成
構成としては以下の通り。

フロントエンド
フロントエンド用に作成するリソースは、フロントエンド資材を配置するS3バケットだけです。
S3バケットの作成
フロントエンドの資材の配信元となるS3バケットを作成します。

バケット名を入力して、それ以外はデフォルトで大丈夫です。バケットを作成ボタンをクリックしてバケットを作成します。


バックエンド
バックエンド用に作成するリソースは、API GatewayとLambdaの2つです。
Lambdaの作成
ユーザー情報を取得するLambda関数を作成します。

一から作成を選択し、関数名を入力し、ランタイムはNode.js 20.xにします。その他の設定はデフォルト設定とします。

作成が出来たら、コードタブに移動します。

index.mjsにユーザー情報を取得するコードを書きDeployボタンをクリックします。

コードとしては以下の通りです。
/* global fetch */
export const handler = async (event) => {
const responseBase = {
headers: {
"content-type": "application/json",
"Access-Control-Allow-Origin": "*"
}
};
try {
const users = await fetch("https://jsonplaceholder.typicode.com/users").then(
(response) => response.json()
);
return {
...responseBase,
statusCode: 200,
body: JSON.stringify({
users
})
};
} catch (err) {
console.log(err);
return {
...responseBase,
statusCode: 500,
body: JSON.stringify({
error: JSON.stringify(err)
})
};
}
};

最後にTestボタンをクリックして、正常に取得できているかを確認します。

API Gatewayの作成
APIの入り口となるAPI Gatewayを作成します。REST APIの構築ボタンをクリックします。

新しいAPIを選択し、API名を入力し、APIエンドポイントタイプはリージョンを選択します。APIを作成ボタンをクリックして、APIを作成します。

ユーザー情報の取得のためのリソースを作成していきます。リソースを作成ボタンをクリックします。

リソース名に「users」を入力し、CORS (クロスオリジンリソース共有)にチェックをして、リソースを作成ボタンをクリックします。

作成されたらメソッドを作成ボタンをクリックします。

以下の通り入力し、その他はデフォルトのままで、メソッドを作成ボタンをクリックします。
メソッドタイプ: GET
統合タイプ: Lambda関数
Lambdaプロキシ統合: チェック
Lambda関数: 先ほど作成したLambda関数を選択

作成が完了したらテストタブをクリックします。

テストボタンをクリックして、Lambdaの呼び出しに成功するか確認します。

APIをデプロイボタンでデプロイを行います。

ステージは新しいステージを選択し、ステージ名はapiを入力し、デプロイボタンをクリックします。

デプロイが完了したらURLを呼び出すに記載されているURLに/usersを追加して、ブラウザからアクセスが出来るか確認します。

以下の通りJSONの情報が返ってきていれば確認が完了です。

WAF
アプリケーションを保護するためのWAFを作成します。
Web ACLの作成
Global (CloudFront)を選択し、Create web ACLボタンをクリックします。

Resource typeはAmazon CloudFront distributionsを選択し、Nameを入力し、その他はデフォルトのままでNextボタンをクリックします。

Add rulesをクリックし、Add managed rule groupsをクリックします。
※今回はマネージドルールだけを追加します。最後にあるCDKサンプルではIP制限も入れています。

AWS managed rule groupsを開き、Free rule groupsのCore rule setのAdd to web ACLをONにします。


Rulesにルールが追加されたことを確認し、Default actionはAllowを選択し、Nextボタンをクリックします。

ルールの優先順位については、今回は1つだけしか入れていないので、そのままNextボタンをクリックします。

メトリクスの設定は、今回はデフォルトのままで、Nextボタンをクリックします。

設定内容を確認して、Create web ACLボタンをクリックしてWAFを作成します。



CloudFront
フロントエンド用S3バケットのオリジンアクセスコントロールの作成
CloudFrontからフロントエンドのソースが配置されているS3バケットにアクセスするためのオリジンアクセスコントロール(以降OAC)を作成します。CloudFrontのコンソールから、セキュリティのオリジンアクセスに遷移します。コントロール設定を作成ボタンをクリックします。

OACの名前を入力し、オリジンタイプはS3に設定してCreateボタンをクリックしてOACを作成します。


CloudFrontディストリビューションの作成
CloudFrontのディストリビューションを作成します。

Origin domainには、作成したS3バケットを指定します。名前は、S3バケットを選択した際に自動で設定されますので、そのままでも変えてもどちらでも良いです。本記事ではそのままで行きます。
オリジンアクセスは、Origin access control settings (recommended)を選択し、先ほど作成したOACを選択します。

ビューワープロトコルポリシーは、Redirect HTTP to HTTPSを選択し、キャッシュキーとオリジンリクエストには、Cache policy and origin request policy (recommended)で、CachingOptimizedを選択します。

WAFは、セキュリティ保護を有効にするを選択し、既存のWAF設定を使用を選択し、先ほど作成したWAFを選択します。

料金クラスは、日本向けと仮定して、北米、欧州、アジア、中東、アフリカを選択しておきます。デフォルトルートオブジェクトは、index.htmlを入力します。

その他はデフォルトのままで、ディストリビューションを作成ボタンをクリックして、ディストリビューションを作成します。

作成されたらポリシーをコピーボタンが黄色の帯の右側にあるので、クリックしてポリシーをコピーしておきます。

オリジンS3バケットのバケットポリシーを設定
S3バケットに戻り、アクセス許可のタブのバケットポリシーの編集ボタンをクリックします。

先ほど、CloudFrontのディストリビューションを作成後にコピーしたポリシーを、ポリシー欄にペーストして、変更の保存ボタンをクリックします。


CloudFrontとS3の連携の確認
想定通りS3からフロントエンド資材を取得できているか確認します。作成したS3バケットに適当に作成したindex.htmlファイルをアップロードします。

作成したCloudFrontのディストリビューションのディストリビューションドメイン名をコピーしてブラウザでアクセスして表示されれば確認完了です。

API Gatewayのオリジンとビヘイビアを追加
CloudFrontとAPI Gatewayを連携させるため、ディストリビューションにオリジンとビヘイビアを追加します。
オリジンタブのオリジン作成ボタンをクリックします。

Origin domainに作成したAPI Gatewayを選択します。
カスタムヘッダーとしてRefererを追加し、値には適当な設定します。このカスタムヘッダーは、APIのリクエストもWAFを通したいため、CloudFront経由でしかAPI Gatewayにアクセス出来ないようにするためのものです。後でAPI Gatewayのリソースポリシーにも設定します。ただし、この値を知っていればアクセス出来てしまいますので、推測の出来ない値を設定する必要があります。
※ API Gatewayで、CloudFrontのこのディストリビューションから、みたいなリソースポリシーが設定できないか調べたのですが、見つけられずこの方法を取ることにしました。
オリジンを作成ボタンをクリックします。

追加ができたら、ビヘイビアタブをクリックし、ビヘイビアを作成ボタンをクリックします。

パスパターンに「/api/*」オリジンとオリジングループに先ほど作成したAPI Gatewayのオリジンを選択します。ビューワープロトコルポリシーはRedirect HTTP to HTTPSを選択し、許可された HTTP メソッドはGET, HEAD, OPTIONS, PUT, POST, PATCH, DELETEを選択します。

キャッシュポリシーにはCachingDisabled、オリジンリクエストポリシーには、AllViewerExceptHostHeaderを選択して、ビヘイビアを作成ボタンをクリックします。


API Gatewayのリソースポリシーの作成
先ほど、API Gatewayのオリジンで、Refererをカスタムヘッダーとして追加しました。それをAPI Gatewayへのアクセス制限として利用するため、API Gatewayのリソースポリシーを設定します。

ポリシーの詳細のエリアに、Refererが一致しなければ拒否するというポリシーの設定と通常は許可をする設定を入力し、変更を保存ボタンをクリックします。

ポリシーとしては以下の通りです。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:ap-northeast-1:{AccountのID}:hu32j8ntf8/*/*/*",
"Condition": {
"StringNotEquals": {
"aws:Referer": "5MvvYcvcjGPHg6CP2vWA"
}
}
},
{
"Effect": "Allow",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:ap-northeast-1:{AccountのID}:hu32j8ntf8/*/*/*"
}
]
}
設定が保存されたらリソースへ移動して、APIをデプロイボタンをクリックして、リソースポリシーの変更をデプロイします。


CloudFrontとAPI Gatewayの連携の確認
デプロイが完了したらURLを呼び出すに記載されているURLに/usersを追加して、ブラウザからアクセスして、想定通り拒否されるか確認します。

次に、CloudFrontのディストリビューション経由であれば、ユーザー情報が取得できることを確認します。

フロントエンド資材のデプロイ
AWS環境の方は準備ができたので、フロントエンドの資材をビルドして、アップロードします。CI/CDは用意していないので、ローカル環境でビルドを行いS3にアップロードします。


動作確認
CloudFrontディストリビューションのキャッシュの削除
先ほど動作確認でアクセスしているため、キャッシュを削除しておきます。キャッシュ削除タブのキャッシュ削除を作成ボタンをクリックします。

オブジェクトパスを追加に「/*」を入力し、キャッシュ削除を作成ボタンをクリックします。

スタータスが完了済みになるまで待ちます。

フロントエンドの表示確認とバックエンドとの疎通確認
作成したCloudFrontのディストリビューションのURLにブラウザでアクセスして表示され、取得ボタンをクリックしてユーザー情報が表示されれば、確認完了です。


WAFのサンプリングの確認
作成したWAFのWeb ACLのSampled requestsタブに移動し、WAFを利用できているか確認します。APIのパスである「/api/users」もフロントエンドの資材もあるため、問題なく利用ができていることが確認できます。

事後処理
今回作成したリソースの削除は以下になります。
CloudFront
作成したCloudFrontのディストリビューションを削除する
無効にする
最終変更日がデプロイから日付に変わるまで待つ
削除する
作成したCloudFrontのオリジンアクセスを削除する
S3
作成したバケットを空にする
作成したバケットを削除する
API Gateway
作成したREST APIを削除する
Lambda
作成した関数を削除する
WAF
作成したWeb ACLを削除する
CloudWatch Logs
自動で作成されたLambdaのロググループを削除する(/aws/lambda/Lambda関数名)
IAM
自動で作成されたポリシーを削除する
作成時刻を見て、直近のAWSLambdaBasicExecutionRole-xxxxxxのポリシーを削除
自動で作成されたロールを削除する
cloudfront-s3-api-waf-get-users-role-xxxのロールを削除(念のため作成時刻を確認して実施する)
まとめ
今回はCloudFrontにWAFを設定して、フロントエンドとAPIを保護するということをやってみました。これである程度は悪意のある攻撃からWebアプリを守ることが出来そうです。WAFの他のマネージドルールやカスタムルールなども作成できるため、どこまで防御するのかなどを決めて、設定していけば良いというスタート地点までは出来たかなと思います。
もちろん、フロントエンド、API自体などでもセキュリティ対策は必要になるのですが、WAFがあるだけで、少し安心が出来ますね。
CDK版も作ってみました。