KINTO Tech Blog
AWS

Amplify Hosting + CDK 環境で WAF が 2 系統に分かれていた話

Cover Image for Amplify Hosting + CDK 環境で WAF が 2 系統に分かれていた話

はじめに

こんにちは、 Cloud Infrastructure G の山中です!

「拠点のグローバル IP が変わったので、AWS WAF の allowlist を更新してください」というよくある作業を引き受けたところ、半日以上溶かしました。

原因は 同じシステムの中に WAF が 2 系統 存在しており、片方を更新しても、もう片方が古い IP リストを握っていたためです。さらに Amplify Console の Firewall UI が「Firewall: 無効」と表示しているのに、API で確認すると WebACL がしっかり attach されている という UI と実態の乖離まで重なり、見えている情報をそのまま信じてよいか判断がつかない状況でした。

この記事では、その 2 系統 WAF の全体像と、UI を信用できないときに API で実態を確認する手順を共有します。同じような構成(Amplify Hosting + CDK 管理の WAF)を運用している方の参考になれば幸いです。

TL;DR

  • 拠点 IP 変更で全環境 403 が発生
  • Backend API 側の WAF(CDK 管理)は Amplify の環境変数 + rebuild で解消できた
  • しかしフロントエンド側は Amplify Hosting が自動管理する別 WAFAmplifyIPSet-*)が原因で 403 が残った
  • さらに Amplify Console の Firewall UI は「Firewall: 無効」表示なのに、API では WebACL が attach 済み・ default action が Block だった
  • aws wafv2 list-resources-for-web-acl--resource-typeAMPLIFY にしないと、attach 状態が見えない罠も踏んだ
  • 最終的に aws wafv2 update-ip-set で直接 IPSet を書き換えて解決
  • 教訓:Amplify Hosting の WAF は UI を信用せず、API で実態を確認すべし

背景

サービス構成

ユーザー向けに提供予定のフロントエンド + バックエンド API のシステムで、構成は以下の通りです。

  • バックエンド: AWS Amplify Gen 2(CDK で WAF や CloudFront を含むインフラを定義)
  • フロントエンド: AWS Amplify Hosting Gen 1(platform=WEB)
  • CDN: CloudFront
  • WAF: AWS WAFv2
  • 環境: dev / stage / prod(それぞれ別 AWS アカウント)

アクセス制御の方針

まだリリース前のシステムのため、複数拠点からのオフィス IP のみを許可しています。
拠点が増減したり、回線変更で IP が変わったりすると、各環境の WAF の IPSet を更新する必要があります。

出来事

ある日「拠点 A が新しい IP に切り替わるので、X 日までに各環境の allowlist を入れ替えてほしい」という依頼が来ました。
作業手順は社内に整備されていたので、淡々と進めていたつもりだったのですが、ここから泥沼に入っていきます。

WAF が 2 系統に分かれている全体像

最初に結論を絵にしておきます。後の章で何度もこの絵に戻ってきます。

Backend API 系(CDK 管理の WebACL/IPSet)と Frontend 系(Amplify Hosting が自動管理する WebACL/IPSet)が並列に存在する WAF 2 系統構成図

ポイントは、同じ「拠点 IP 許可」という概念を、別々のリソースとして 2 箇所で独立に管理している ことです。

  • Backend API 側: CDK / CloudFormation で IPSet を定義 → Amplify の環境変数 WAF_ALLOWED_IP_LIST を更新して rebuild すれば IPSet が書き換わる、という仕組みを自前で組んでいる
  • Frontend 側: Amplify Hosting の「Firewall(AWS WAF 統合)」機能を有効にすると、Amplify サービス側で勝手に IPSet と WebACL を作成し、Amplify app にくっつける

片方しか更新しないと、当然、もう片方の経路で 403 が出ます。今回まさにそこにハマりました。

タイムライン

実際に起きた流れを表にすると、こうなります。

タイミング 出来事
Day 0 社内ドキュメントで「新しい拠点 IP 一覧」が共有される
Day 1 dev の Backend API WAF を Amplify の環境変数経由で更新 → IPSet 反映確認
Day 2 朝 stage の Backend API WAF を更新 → IPSet 反映確認
Day 2 昼 prod の Backend API WAF を更新 → IPSet 反映確認
Day 2 昼 動作確認のためフロントエンド URL にアクセス → まだ 403
Day 2 昼 「Backend は更新したのに何で?」と調査開始
Day 2 午後 フロントエンドは別 WAF (AmplifyIPSet-*) で守られていることに気付く
Day 2 午後 Amplify Console の Firewall UI を見る → 「Firewall: 無効」と表示
Day 2 午後 API で確認 → WebACL は attach 済み、IPSet は旧 IP のままで Block
Day 2 夕方 aws wafv2 update-ip-set で 3 環境の AmplifyIPSet-* を直接更新 → 全環境 200 OK

「Backend は更新したのにフロントだけ 403」となった時点で、Amplify Hosting 側に別 WAF があると気付くまでが一番遠回りでした。

最初の対応 — Backend API WAF はあっさり直る

Backend API 側の更新手順は、すでに社内で整備されていました。

  1. Amplify Console で対象 app の環境変数 WAF_ALLOWED_IP_LIST を新しい IP の CSV に書き換える
  2. Amplify の build を回す
  3. CDK で定義された CfnIPSet のリソースが新しい IP で更新される

この仕組みのおかげで、dev / stage / prod の Backend API は Day 1 から Day 2 にかけて順次切り替え、いずれも更新自体は数分で完了しました。
WAFv2 のコンソールで IPSet を覗いて、新しい IP が並んでいるのを確認 → よし、終わったな、と思ったのが甘かったです。

念のため、フロントエンドの URL にも curl を投げて確認しました。

$ curl -i https://<env>.example.internal/
HTTP/1.1 403 Forbidden
content-type: text/html
...
<HTML><HEAD><TITLE>Request blocked.</TITLE></HEAD>
<BODY>Request blocked.</BODY></HTML>

Request blocked. というレスポンスボディは、AWS WAF の Block ルールが返す典型的な文字列です。S3 オリジンの「Access Denied」とは別物なので、これが見えた時点でほぼ WAF を疑って間違いありません。

つまり Backend は直ったが、フロントエンドの経路では別の何かが Block している、という状況です。

真犯人を探す — フロントエンドは別 WAF で守られていた

「フロントエンドも CloudFront → S3 のはず。WAF はどこに付いているんだろう?」と探したところ、Amplify Hosting には AWS WAF 統合(Firewall) という機能があり、これを有効化していたことを思い出しました。

Amplify Hosting の Firewall を有効化すると、Amplify サービス側で以下を自動的に作成・関連付けします。

  • AmplifyIPSet-<guid> という名前の IPSet(us-east-1, scope=CLOUDFRONT)
  • CreatedByAmplify-<appId>-<guid> という名前の WebACL
  • 上記 WebACL を Amplify app のリソース ARN に AssociateWebACL で紐付け

そして これらのリソースは CloudFormation / CDK の管理外 です。Amplify サービスが直接 WAF API を叩いて作成しています。
そのため、CDK 側でいくら WAF を更新しても、フロントエンドの WAF は変わりません。

WAFv2 のコンソールから IPSet の一覧を眺めると、確かに AmplifyIPSet-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX のような IPSet が、CDK 由来の my-app-api-allowed-ips-<env> とは別に存在していました。中身を見ると、まさに 旧拠点 IP のみが入っている 状態。これが原因です。

# 正しい(CloudFront scope は us-east-1 で見る)
aws wafv2 list-ip-sets --scope CLOUDFRONT --region us-east-1

# 間違い(REGIONAL scope のものしか返ってこない)
aws wafv2 list-ip-sets --scope REGIONAL --region ap-northeast-1

UI と API が一致しない問題

「じゃあ Amplify Console の Firewall 画面で IP を入れ替えればいいか」と思って画面を開いたところ、目を疑う表示が出ていました。

Firewall: 無効(このアプリは Web Application Firewall で保護されていません)

つまり「WAF はかかっていません」という意味の表示です。
しかし curl を打つと、明らかに 403 (Request blocked.) が返ってくる。どちらを信じればよいのか分からない状況です。

ここで AWS CLI を使って、API レベルで実態を確認します。

1. Amplify app に WebACL が attach されているかを確認

list-resources-for-web-acl を使うと、ある WebACL がどのリソースに attach されているかが分かります。

aws wafv2 list-resources-for-web-acl \
  --region us-east-1 \
  --web-acl-arn arn:aws:wafv2:us-east-1:XXXXXXXXXXXX:global/webacl/CreatedByAmplify-XXXXXXXXXX-XXXXXXXX/XXXXXXXX

これだけだと 空の配列が返ってきます

「あれ、attach されていない?じゃあ何が Block しているの?」と一瞬混乱しました。
ですがこれは罠で、--resource-type を指定していないとデフォルト値 APPLICATION_LOAD_BALANCER で検索されます。Amplify Hosting の場合、resource-type は AMPLIFY なので、デフォルトでは見えません。
さらに list-resources-for-web-aclそもそも CloudFront Distribution には使えません(CloudFront の関連付けを調べたいときは aws cloudfront list-distributions-by-web-acl-id を使うのが正解です)。

# 正しい呼び方
aws wafv2 list-resources-for-web-acl \
  --region us-east-1 \
  --web-acl-arn arn:aws:wafv2:us-east-1:XXXXXXXXXXXX:global/webacl/CreatedByAmplify-XXXXXXXXXX-XXXXXXXX/XXXXXXXX \
  --resource-type AMPLIFY

これでようやく、Amplify app の ARN が返ってきました。UI は「Firewall: 無効」と言っていたが、実態としては WebACL がしっかり attach されていた わけです。

2. WebACL のデフォルトアクションを確認

get-web-acl でデフォルトアクションを覗くと、default は Block、その上で IPSet ベースの allow ルールが乗っかっている構成でした。

aws wafv2 get-web-acl \
  --scope CLOUDFRONT --region us-east-1 \
  --name CreatedByAmplify-XXXXXXXXXX-XXXXXXXX --id XXXXXXXX \
  --query 'WebACL.{DefaultAction:DefaultAction, Rules:Rules[].Name}'

つまり、IPSet に載っていない IP からのアクセスは全部 Block
UI 上は「Firewall: 無効」と表示していても、API レベルでは「Block ベース + 古い IPSet で allow」になっており、見事に食い違っていました。

3. なぜ乖離したのか — CloudTrail で犯人探し

UI と API がここまで一致しないのは流石におかしいので、CloudTrail で履歴を漁ってみました。

aws cloudtrail lookup-events \
  --max-items 50 \
  --lookup-attributes AttributeKey=EventName,AttributeValue=UpdateIPSet \
  --output json

AssociateWebACLDisassociateWebACL も同じように引いて、時系列で並べると、過去にある担当者が 何度も Associate / Disassociate を繰り返しており、最終的に Associate で終わっていた ことが分かりました。
時系列を追っても、UI が「Firewall: 無効」を表示し続けるに至った直接的な原因までは特定できませんでした。ただし「Amplify Console の Firewall UI と、WAFv2 API が示す実態とが食い違うケースが起こりうる」という事実だけは、今回の調査で確認できたことになります。

ともあれ、API の実態を信じるしかないことが確定したので、次は IPSet を直接書き換えに行きます。

解決手順 — IPSet を直接更新

すでに AmplifyIPSet-* がどこにあり、どの WebACL に紐付いているかは分かっているので、あとは WAFv2 の IPSet を直接更新 すれば終わりです。
ただし WAFv2 には楽観的排他制御の仕組みがあって、LockToken を毎回取り直す必要があります。

実際に流したコマンドの雛形がこれです。

# 環境変数で接続先を切り替え(dev/stage/prod ごとに AWS_PROFILE を変える)
export AWS_PROFILE=my-app-prod
IPSET_NAME=AmplifyIPSet-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
IPSET_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX

# 1) 現在の LockToken を取得
LOCK=$(aws wafv2 get-ip-set \
  --scope CLOUDFRONT --region us-east-1 \
  --name "$IPSET_NAME" --id "$IPSET_ID" \
  --query "LockToken" --output text)

# 2) IPSet の中身を新しい IP リストで上書き
aws wafv2 update-ip-set \
  --scope CLOUDFRONT --region us-east-1 \
  --name "$IPSET_NAME" --id "$IPSET_ID" \
  --lock-token "$LOCK" \
  --addresses 192.0.2.10/32 192.0.2.11/32 198.51.100.0/24 203.0.113.0/24

これを dev / stage / prod の 3 環境で順に実行し、それぞれの環境で curl を打って 200 OK を確認しました。

学んだこと

1. 「同じ目的のリソースが複数経路で管理されている」状態を疑う

今回は、my-app-api-allowed-ips-<env>AmplifyIPSet-* という、同じ「拠点 IP 許可リスト」を別々の場所で独立に管理している 構造が根本原因でした。
インフラを段階的に作っていくと、こうした「二重管理状態」がいつのまにか出来上がっていることがあります。今回のような全社的な IP 変更のタイミングは、その整理の絶好の機会でもあるな、と感じました。

2. UI を信用せず、API で実態を確認する癖を付ける

Amplify Console のような上位のマネジメントコンソールは、内部で何かをキャッシュしていたり、過去の状態を表示し続けていたりすることがあります。
今回のように UI 表示 (「Firewall: 無効」) と API が示す実態 (Block ルール有り) が一致しないパターンも、十分起こり得ます。

3. list-resources-for-web-acl--resource-type を忘れない

WAFv2 の list-resources-for-web-acl は、--resource-type を省略すると デフォルト値 APPLICATION_LOAD_BALANCER で検索されます。Amplify Hosting の場合は AMPLIFY を明示する必要があります。
また、この API は CloudFront Distribution を対象に取れません。CloudFront の関連付けは aws cloudfront list-distributions-by-web-acl-id という別の API を使うことになっており、これを知らないと「該当 WebACL がどこにも attach されていない」と誤認してしまいます。
今回も、ここに気付くまでが一番大きく時間を溶かしたポイントでした。

4. CloudFront scope の WAF は us-east-1 にしかない

これは基礎中の基礎なのですが、改めて。
WAFv2 で CloudFront に付けるリソースは 必ず us-east-1 にあります。CLI なら --region us-east-1 必須、コンソールなら左上のリージョンを Global (CloudFront) に切り替える必要があります。

5. 「Request blocked.」というレスポンスボディは WAF Block の典型

CloudFront 経由の 403 で、ボディに Request blocked. の文字列があれば、ほぼ WAF の block ルールが原因です。S3 の Access Denied とは見た目で区別できるので、最初の切り分けに便利です。

使ったコマンドまとめ

トラブルシュート中に何度も叩いたコマンドを、ここに集めておきます。

IPSet 周り

# IPSet 一覧(CloudFront scope)
aws wafv2 list-ip-sets --scope CLOUDFRONT --region us-east-1

# IPSet の中身を確認
aws wafv2 get-ip-set \
  --scope CLOUDFRONT --region us-east-1 \
  --name <name> --id <id> \
  --query "IPSet.Addresses" --output json

# IPSet の更新(LockToken 必須)
LOCK=$(aws wafv2 get-ip-set \
  --scope CLOUDFRONT --region us-east-1 \
  --name <name> --id <id> \
  --query "LockToken" --output text)

aws wafv2 update-ip-set \
  --scope CLOUDFRONT --region us-east-1 \
  --name <name> --id <id> \
  --lock-token "$LOCK" \
  --addresses 192.0.2.0/24 198.51.100.0/24

WebACL の attach 先確認

# Amplify Hosting に付いているかを確認(--resource-type を忘れない!)
aws wafv2 list-resources-for-web-acl \
  --region us-east-1 \
  --web-acl-arn <arn> \
  --resource-type AMPLIFY

CloudTrail で履歴を追う

# 特定の API イベントを引く
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=UpdateIPSet \
  --max-items 20 --output json

# 特定リソースに対する操作履歴
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceName,AttributeValue=<arn>

まとめ

Request blocked. だけが手がかりの 403 から、Amplify Hosting 裏の WAF の存在に気付き、UI と API の乖離まで辿り着いた、というやや遠回りなトラブルシュート記でした。

整理すると、今回の学びは次の通りです。

  • 同じ目的のリソースが 複数経路 で管理されていないかを疑う
  • マネジメントコンソールの UI は実態と一致しないことがある。最後の真実は API レスポンス
  • WAFv2 の list-resources-for-web-acl--resource-type を忘れずに
  • CloudFront scope の WAF は 必ず us-east-1
  • WAFv2 の更新は LockToken をセットで 扱う

Amplify Hosting の Firewall (AWS WAF 統合) は便利ですが、「いつのまにか UI と実態が乖離する」リスクがあることは覚えておいて損はないと思います。
同じような構成の運用に関わる方の参考になれば嬉しいです。最後まで読んでいただきありがとうございました!

参考リンク

Facebook

関連記事 | Related Posts