Web APIをCloudflare(CDN) × API Gateway(WAF)で保護する

## はじめに

Web API（REST APIのバックエンドのサーバ）を構築するときに、できるだけセキュアで時間課金ではなく従量課金にする方法を考えていた。そしてAWSのリソースを利用してサーバを構築し、オリジンサーバへのアクセスを保護するためにWAFを導入したかった。

ふと、Cloudflare（CDN）とAPI Gatewayの組み合わせで、

- mTLS
- WAFによるHTTPヘッダー検証
 
の組み合わせで保護するのが結構いい感じなのではと思いやってみた。

※AWSでCDNと言えばCloudFrontだが、CloudFrontについて検討した事は[おまけ](#おまけ)で取り上げたいと思う。

## 今回やろうとしていることの完成図

今回やろうとしていることの最終的な完成図は以下。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/359a64e1-c2c5-e11e-a51c-8ef6db4ffcf0.png)

上記のような構成をとることで、多層防御（Defense in Depth）を実現できる。また、ネットワークでアクセスがどこまで到達するか？を考えてみると、以下のようにオリジンサーバであるLambdaにまで到達しないのが特徴。

- mTLS
  - トランスポート層でTCPコネクションを確立した後にTLSハンドシェイクをする時に行われる。つまり、API GatewayがSSL/TLSの終端になっているので、オリジンサーバであるLambdaまでリクエストは到達しない。これはALBなどでも同じ。
- WAFでのHTTPヘッダー検証
  - mTLSが終わり、実際に暗号通信をしてAPI Gatewayまでリクエストが届くが、そのリクエストの内容はSSL/TLSの終端であるAPI Gatewayで復号される。その後、Lambdaにプロキシされるが、その前にWAFでのチェックが入る。つまり、HTTPヘッダーの検証ではじけば、オリジンサーバであるLambdaまでリクエストは到達しない。これもまたALBでも同じ。

※Web APIの保護という点では、OAuth2.0のアクセストークン（JWT）を利用することが多いと思われるが、それについては本記事では触れない。OAuth2.0のアクセストークンでの保護はアプリケーション層での保護に該当する。

では実際にやっていきたいと思う。

## その前に　API Gateway＋Lambda関数の準備

今回はAPI Gatewayでのカスタムドメイン設定を行い、mTLS（相互TLS認証）の設定・WAFの設定をするのがメインなので、Lambda関数は適当に200・hello worldを返す関数とする。以下のテンプレートを少しいじって作ると楽。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/43859371-41ca-5339-da8c-e76b5345302c.png)

Deployした関数のコードは以下。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/199edc98-0f36-16bd-88c6-8ce579b1bb45.png)

続いて、API Gatewayと統合する。マネージメントコンソールの「トリガーの追加」からだとAPIキーが必要になってしまったり不便なのでAPI Gatewayのメニューから作成する。
`REST API を作成`からAPIを作成し、`リソースを作成`からプロキシのリソースを作成する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/3b009840-d20c-797b-0832-cd2373003e58.png)

続いて`メソッドを作成`から`/{proxy+}`に対して`ANY`を設定し、`統合タイプ`にLambdaを選択して先ほど作成したLambda関数を選択する。ここまで出来たらいったん`テスト`からLambdaとうまく統合できているか？を確認しておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/2dc35626-3bac-5651-1c4e-2e4832281aa9.png)

問題なければ`APIをデプロイ`から適当なステージ名をつけてデプロイし、今度はcurl等で今設定したAPIを呼び出してみる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/d8e59bf3-1e00-23d3-851c-f298ad939461.png)
```console
$ curl https://<APIのID>.execute-api.ap-northeast-1.amazonaws.com/default/api/test
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    11  100    11    0     0     54      0 --:--:-- --:--:-- --:--:--    55
hello world
```

ここまでで事前準備は完了。以降では、カスタムドメインの設定をして、mTLS（相互TLS認証）の設定・WAFの設定をすることで、API Gatewayのエンドポイントを保護してみたいと思う。

## API GatewayでのmTLSの設定

まず、mTLSの設定には[Configuring mutual TLS authentication for a REST API](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-mutual-tls.html#rest-api-mutual-tls-prerequisites)に書かれている通り、API Gatewayのカスタムドメインの設定が必要になる。そしてカスタムドメインの設定の際にはサーバ証明書が、またmTLSの設定時にはクライアント証明書の指定も必要になる。

色々やることがあるが、以下の順にやっていく。

1. SSLサーバ証明書をACM（AWS Certificate Manager）に登録する
1. ドメイン所有のパブリック証明書の発行する
1. 信頼ストアとしてmTLSで利用するクライアント証明書（公開鍵）を配置する
1. API Gatewayでカスタムドメインの作成をする（mTLSは設定しない）
1. 4の手順で設定したカスタムドメインの名前解決が行われるようにDNSでCNAMEレコードを設定する
1. mTLSの設定を有効にする

### 1. SSLサーバ証明書をACM（AWS Certificate Manager）に登録する 

まずはACMにカスタムドメインに対するSSLサーバ証明書が必要になる。今回はCloudflareをCDNとして利用するので、Cloudflareで発行したオリジン証明書（サーバ証明書）を利用する。

ACMの`証明書をインポート`からCloudflareで発行したサーバ証明書を入力する（サーバ証明書の発行については[CloudflareのSSL/TLSについて](https://qiita.com/yuta-katayama-23/items/cda5d533a41845a2fa2e#cloudflare%E3%81%AEssltls%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)を参照）。インポートできると以下のように表示される。なお、Cloudflareのオリジン証明書は自前で発行したものに等しいので自動更新されないことに注意。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/58089d6c-4996-5a11-d71c-9c2f8f8e1210.png)

### 2. ドメイン所有のパブリック証明書の発行

パブリック証明書とは、カスタムドメインの設定の以下で指定するACMの証明書のことで、ドメインの所有者であることを証明するために必要。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/a63826e1-c74a-6758-7769-9bf33e90992a.png)

ドメインの所有確認は、DNSのCNAMEを設定して行う方法が簡単なのでそれを行う。まず`証明書をリクエスト`から証明書の発行を依頼する。依頼すると保留中になってDNSで設定すべきCNAMEレコードの内容が表示されるのでその通りに設定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/418133fc-949d-8a9f-f7b0-306218caa976.png)

お名前ドットコムでドメインは取得したが、ドメインの管理はCloudflareのネームサーバで行うように設定していたので、CloudflareのDNSメニューから以下のようにCNAMEを設定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/4451cd85-6b29-d063-fceb-f64cae4a4d1f.png)

うまくいくと以下のようにステータスが〇〇になる（DNSの設定を更新してから10分くらいはかかった気がする）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/cd13ed1e-7670-0dba-04e2-65dbf19bee2e.png)

※`ownershipVerificationCertificate does not cover one or more subjects in the imported/private certificate.`みたいなエラーが出るときは、1で登録したサーバ証明書のドメインと乖離があるので、そこを確認する。

- 参考
  - [Requesting a public certificate](https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-request-public.html) 
  - [Getting certificates ready in AWS Certificate Manager](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-custom-domains-prerequisites.html)

### 3. 信頼ストアとしてmTLSで利用するクライアント証明書（公開鍵）を配置する

[Updating your truststore](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-mutual-tls.html#rest-api-mutual-tls-update-truststore)に書かれている手順。
この手順は、mTLSではクライアント証明書を使ってリクエストを送ってきたクライアントを信頼するが、仕組みとしてはTLSハンドシェイクの時にクライアントから送られてくる署名データを公開鍵（クライアント証明書）で検証するので、そのためのクライアント証明書をS3に置くという事をやる。

適当なバケットを作成してそこにCloudflareが用意しているクライアント証明書をアップロードする（[1. Upload certificate to origin](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/set-up/zone-level/#1-upload-certificate-to-origin)からCloudflareのクライアント証明書は取得できる）。

API Gatewayのカスタムドメインの設定ではS3のURIを利用するので、それを控えておく。
```
s3://<バケット名>/ssl/authenticated_origin_pull_ca.pem
```

#### 4. API Gatewayでカスタムドメインの作成をする（mTLSは設定しない）

続いて、API Gatewayのカスタムドメインの設定をする。まずはカスタムドメインの設定後に疎通できるか？を確認したいので、シンプルにmTLSの設定はしないでやってみる。

API Gatewayの画面の`カスタムドメイン名`メニューから作成する。エンドポイント設定で1の手順で登録したACMのサーバ証明書を選択する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/8bbb931e-44fd-69a6-7c46-793c6979598c.png)

設定がうまくいくと以下のように利用可能なステータスになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/55833583-bb7d-6ab6-28a6-37575cabe13c.png)

どのAPIをこのカスタムドメインにマッピングするのかを設定するために、`APIマッピング`から作成済みのAPIとのマッピングを作成する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/659665a3-279c-113f-357f-a42784a7194f.png)

これでカスタムドメインの設定は（mTLS以外）については一旦完了になる。

参考
- [Setting up custom domain names for REST APIs](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-custom-domains.html)

### 5. 4の手順で設定したカスタムドメインの名前解決が行われるようにDNSでCNAMEレコードを設定する

4の手順を完了しただけでは、カスタムドメインで設定したドメイン`restapi.<取得したドメイン>.com`の名前解決が行われないので以下のようにエラーになる。
```console
$ curl restapi.<取得したドメイン>.com
curl: (6) Could not resolve host: restapi.<取得したドメイン>.com

$ nslookup restapi.<取得したドメイン>.com
Server:         192.168.11.1
Address:        192.168.11.1#53

** server can't find restapi.<取得したドメイン>.com: NXDOMAIN
```

名前解決されるように、DNSで以下のようにCNAMEレコードを設定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/cc5b6fac-9dad-218f-80a0-190abfb1dcfa.png)

:::note warn
ここでCNAMEレコードの値に設定するFQDNはカスタムドメインの詳細のAPI Gatewayドメイン名に記載されたものなので注意。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/15969ffc-ba8e-fca6-657b-d994cc034c98.png)
APIの`ステージ`から確認できる以下のURIのドメインではない。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/b044e66d-f7d8-734c-a1a6-5cfeb3627f80.png)
:::

この設定をすれば以下のようにAPI Gatewayのカスタムドメインで設定した（サブ）ドメインで名前解決されるようになり、API GatewayのエンドポイントのURIではなく、カスタムドメインでAPI Gatewayのエンドポイントにアクセスできる。
```console
$ nslookup restapi.<取得したドメイン>.com
Server:         192.168.11.1
Address:        192.168.11.1#53

Non-authoritative answer:
Name:   restapi.<取得したドメイン>.com
Address: 104.21.76.226
Name:   restapi.<取得したドメイン>.com
Address: 172.67.201.235

$ curl https://restapi.<取得したドメイン>.com/api/test
hello world
```

ちなみに、CloudflareのCND経由でAPI Gatewayにアクセスしているので、レスポンスヘッダーをみると色々各サービスでヘッダーが追加されていることも確認できる（セキュリティ的にはこれらのヘッダーは意図的に隠すべきものもあると思われる）。
```console
$ curl https://restapi.<取得したドメイン>.com/api/test -I
HTTP/2 200 
date: Fri, 31 May 2024 06:50:10 GMT
content-type: application/json
content-length: 11
x-amzn-requestid: 6ba1beda-4dae-4365-8e3b-ae9a854e5cec
x-amzn-remapped-content-length: 11
x-amz-apigw-id: ........
x-amzn-trace-id: Root=........;Parent=........;Sampled=0;lineage=71f4e8d9:0
cf-cache-status: DYNAMIC
report-to: {"endpoints":[{"url":"........"}],"group":"cf-nel","max_age":604800}
nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
server: cloudflare
cf-ray: ........
alt-svc: h3=":443"; ma=86400
```

CloudWatchのログも確認してみると、以下のように確認できるので疎通に問題ないと判断できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/0ec87059-750b-6e9b-afa7-e539d6af62d8.png)

### 6. mTLSの設定を有効にする

API Gatewayのカスタムドメインの設定で、mTLSを有効にしCloudflareからのリクエストのみを受け付けるように設定を変更する。カスタムドメインの設定画面の`ドメインの詳細`から以下のように`相互TLS認証`を有効にする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/96413a0e-00fe-75c2-c2a2-8c10455f2e5d.png)

あとは更新が完了するまで待つだけだが、1点注意として、以下のようにAPIマッピングで指定しているAPIのデフォルトのエンドポイントが有効になっていると迂回される[^1]。
[^1]:本来は、カスタムドメインに対してリクエストを送ってもらいCloudflareのCDN経由でAPI GatewayのエンドポイントにアクセスすることでmTLSが行われる。ただ、APIをデプロイしたときに発行されるデフォルトのエンドポイントがある状態では、万が一それがバレルとそこに直接リクエストが送られてきてしまう＝CDNを迂回してオリジンサーバに直接リクエストが来る状態になってしまうので非常にまずい、という事。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/9676900c-0419-c7f7-56bb-762e7b6f72d7.png)

そこで`API の設定`メニューから`デフォルトのエンドポイント`を無効にする設定をする。これで設定したカスタムドメイン以外でのオリジンサーバのエンドポイントにアクセスできないくなり、セキュアな状態を作れる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/e078f4e5-25c8-2100-76ff-f712382e4eb4.png)

:::note info
カスタムドメインの設定をする際にCNAMEレコードの値に設定した`API Gateway ドメイン名`にはアクセスできないのか？という疑問は残っている。curlで直接リクエストを送ってみると403になったので、何らかの方法ではじいているのかもしれない。
ここは調べてみたが情報は出てこずだった。
:::

API GatewayのmTLSの設定ができたら、Cloudflareの方でも`認証済み Origin Pull`を有効にする。これを有効にするとCloudflareからのリクエスト時のTLSハンドシェイクでクライアント証明書を送るようになり、mTLSができるようになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/9abfd4b9-70b3-5915-2262-73e5c4081d5f.png)

設定後、再度リクエストが通るか？を確認してみると以下のように問題なく通ることが確認できる。
```console
$ curl https://restapi.<取得したドメイン>.com/api/test
hello world
```

また、Cloudflareの設定で`認証済み Origin Pull`を無効にしてみると、以下のように[525エラー](https://community.cloudflare.com/t/community-tip-fixing-error-525-ssl-handshake-failed/44256)になることが確認でき、mTLSが有効になっていることが確認できる。
```console
$ curl https://restapi.<取得したドメイン>.com/api/test
error code: 525
```

ここまでmTLSの導入はおしまいになる。続いて、WAFによるHTTPヘッダーの検証の設定をやってみる。

## API GatewayでのWAFの設定

この設定でもいくつかやるべきことがあるので、以下の順にやっていく。

1. Web ACLでHTTPヘッダーのあるキーに対する値の有無で許可するか？のルールを作成する
1. API GatewayでWeb ACLを適用する
1. CloudflareでカスタムHTTPヘッダーを追加する

### 1. Web ACLでHTTPヘッダーのあるキーに対する値の有無で許可するか？のルールを作成する

AWS WAFのWeb ACLsの作成を行う。今回はHTTPヘッダーのあるキーでのルールになるので、カスタムルールを追加する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/31951d0d-6456-e5b7-78c1-9f4b91cad728.png)

以下のような`X-CDN-Secret-Key`というヘッダーキーに対する値が一致するか？というルールを設定する。これでこのカスタムHTTPヘッダーを持たないリクエストはCloudflare（CDN）経由のリクエストではなく、オリジンサーバへの直リクエストと判定できるようになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/c6553ee9-673d-38ac-e564-081dda82fe89.png)

### 2. API GatewayでWeb ACLを適用する

API Gatewayの`ステージ`メニューからステージを編集で以下のように1の手順で作成したWeb ACLを設定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/b5926003-6313-aabd-8498-dabe838db7ce.png)

設定後、カスタムヘッダーがないリクエストを送ってみると、以下のようにForbiddenではじかれることが確認でできる。逆に、ヘッダーがある場合は意図通りにレスポンスが返ってくることも確認できる。
```console
$ curl https://restapi.<取得したドメイン>.com/api/test?query=waf-reject
{"message":"Forbidden"}

$ curl https://restapi.<取得したドメイン>.com/api/test?query=waf-ok -H "x-cdn-secret-key: ....."
hello world
```

また、Web ACLのダッシュボードでも以下のようにブロックされていること・許可されていることが確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/393640ee-4174-d953-fef1-64652deb2cda.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/643467c0-21de-999f-cb53-6fc2c2d930d0.png)

※ちなみに、WAFのログをCloudWatchで確認できるようにするには、Web ACLを作成後、`Logging and metric`タブの`Logging`から有効にする必要がある。

### 3. CloudflareでカスタムHTTPヘッダーを追加する

[Transform Rules](https://developers.cloudflare.com/rules/transform/)に書かれている通り、リクエストヘッダーを書き換える（追加・変更・削除）ことができる（Cloudflareの機能としては他にも色々できる）。今回はすべてのリクエストにWeb ACLのルールで設定したカスタムヘッダーを追加するようにするので、`ルール`のメニューの`変換ルール`から以下のように設定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/5c1acf24-ea50-fff7-8771-6fdf01b751fd.png)

設定後、今度は2でブロックされていた時と同じように、ヘッダーを指定しないでリクエストを送ってみると、問題なくリクエストが成功することを確認できる。
```console
$ curl https://restapi.<取得したドメイン>.com/api/test?query=not-set-header 
hello world
```

WAFのチェック結果を見ても許可されていることが確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/4d311e98-b75e-4837-f601-dae150e9aa58.png)

以上で今回やってみたかったことは終わりになる。

## まとめとして

今回は、Cloudflare（CDN）とAPI Gatewayを利用して、mTLSとWAFでのHTTPカスタムヘッダーの検証の2つを行い、オリジンサーバのエンドポイントを保護するという事をやってみた。今回やってみたことは[Protect your origin server](https://developers.cloudflare.com/fundamentals/basic-tasks/protect-your-origin-server/)に書かれている一部で、アプリケーション層とトランスポート層の2層での保護になっている。

この構成のメリットとしては、

- WAFを除いて従量課金（Cloudflareのフリープランを利用している場合）
- API GatewayはLambdaをはじめとして、様々なAWSのリソースにリクエストを転送できる（ただし、追加の設定が必要になったりするのでデメリットになりうる）
- mTLSでの保護ができる

などがあげられるのではないかと思う。

さらにセキュリティを高めたり、Web APIのベストプラクティスに倣う意味でも、OAuthのアクセストークンやJWTによる制御も追加するのがよいと思われる。

## おまけ
### CDNをCloudFrontにするというアイディアについて

単純にCloudFrontではmTLSができないため。[クライアント側の SSL 認証](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#RequestCustomClientSideSslAuth)や[ALB の相互TLS認証（mTLS）は AWS WAF や CloudFront を経由した場合でも利用可能か教えてください](https://dev.classmethod.jp/articles/tsnote-can-alb-mtls-authentication-used-via-aws-waf-or-cloudfront/)に書かれている通り、クライアント証明書をサポートしていない。

### ALBによるmTLSについて

ALBでは[Application Load Balancer での TLS による相互認証](https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/mutual-authentication.html)にあるように、mTLSをサポートしている。

今回は従量課金で、というのを重視していたのでAPI Gatewayを利用していたが、ALBでもmTLSはサポートされているので選択肢には入ってくると思う。また、オリジンサーバをLambdaではなくEC2やECSなどにするのであれば、ALBを利用した方が設定は楽だったりするかもしれない（API Gatewayの後ろをEC2等にするときは、HTTPカスタム統合などの設定が必要になったりするらしいので（詳細は[HTTP カスタム統合の API Gateway でバックエンドのステータスコードと異なるコードが返ってくるのはなぜでしょうか](https://dev.classmethod.jp/articles/tsnote-http-custom-integration-statuscode-setting/)を参照））。

### 料金について

- AWS
  - API Gateway、Lambdaは無料枠で収まるが、Web ACL（WAF）は時間課金があるので注意
- Cloudflare
  - 無料（今回やった内容であれば完全に無料枠内で収まる。HTTPヘッダーの書き換えもルール10個までは無料枠がある。いや～すごい。）

### API GatewayのログをCloudWatchでみる

IAMの管理ページでAPI GatewayからCloudWatchにアクセスできるロールを作成する。AWS管理ポリシーで`AmazonAPIGatewayPushToCloudWatchLogs`というのがあるのでこれを利用する。

作成したIAMロールのARNを控えてAPI Gatewayの`設定`メニューから以下のように設定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/e412867b-33c3-3161-fe2d-f3400af2b88e.png)