WAFをCloudFormationで作成してIP制限を行いたいが、IPリストの増減のたびにymlファイルを変更したくない!
ということで、それを満たすような作り方をしました。
前提
- CDKが使えるのであればCDKの方が適している
- 動的に追加・削除がしたいような場合は、CDKの方がマッチしています(自由な処理が書けるため)
- そのため本記事の対象は、「CDKが使えない」「既存環境がCloudFormationで作られていてリプレースしたくない」などの方が対象です
- IPリストの増減のたびにデプロイは必要
- これはWAFを使って実現する以上仕方ないです
- 不恰好な方法(yml)です
- が、便利なので使ってます
- 途中シェルスクリプト・デプロイコマンドが登場しますが、MacOSのPCを前提としています
※CDKで構築してみた記事も書きました。
登場するモノ
やりたいこと
- CloudFormationで柔軟なIP制限が可能なWAF(WebACL)を作成する
- -> IPを増減させてもymlファイルの変更がないようにする
- CloudFormationのデプロイはローカルPCからシェルスクリプトでaws cliによって行う
方法
CloudFormationテンプレートファイル(yml)・IPホワイトリスト用テキストファイル・シェルスクリプト(bash)を用意します。
※GitHubにもあります。
CloudFormationテンプレートファイル(yml)
- ファイル名:waf.yml
### WAF v2 ### AWSTemplateFormatVersion: "2010-09-09" Description: WAF v2 for IP Restrict Metadata: "AWS::CloudFormation::Interface": ParameterGroups: - Label: default: "ScopeType" Parameters: - ScopeType - Label: default: "WAFWebACLMetricName" Parameters: - WAFWebACLMetricName - Label: default: "CIDR1" Parameters: - CIDR1 - Label: default: "CIDR2" Parameters: - CIDR2 - Label: default: "CIDR3" Parameters: - CIDR3 - Label: default: "CIDR4" Parameters: - CIDR4 - Label: default: "CIDR5" Parameters: - CIDR5 # ------------------------------------------------------------# # Input Parameters # ------------------------------------------------------------# Parameters: ScopeType: Type: String AllowedValues: ["CLOUDFRONT", "REGIONAL"] WAFWebACLMetricName: Type: String MinLength: 1 MaxLength: 128 AllowedPattern: "[a-zA-Z0-9]*" CIDR1: Type: String Description: White List CIDR 1 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR2: Type: String Description: White List CIDR 2 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR3: Type: String Description: White List CIDR 3 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR4: Type: String Description: White List CIDR 4 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR5: Type: String Description: White List CIDR 5 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' # ------------------------------------------------------------# # Conditions # ------------------------------------------------------------# Conditions: IsCIDR1: !Or - !Not [!Equals [!Ref CIDR1, ""]] - !Equals [!Ref CIDR1, ""] IsCIDR2: !Not [!Equals [!Ref CIDR2, ""]] IsCIDR3: !Not [!Equals [!Ref CIDR3, ""]] IsCIDR4: !Not [!Equals [!Ref CIDR4, ""]] IsCIDR5: !Not [!Equals [!Ref CIDR5, ""]] Resources: # ------------------------------------------------------------# # WAF # ------------------------------------------------------------# WhiteListCIDRSet: Type: AWS::WAFv2::IPSet Properties: Addresses: - !If - IsCIDR1 - !Ref CIDR1 - !Ref "AWS::NoValue" - !If - IsCIDR2 - !Ref CIDR2 - !Ref "AWS::NoValue" - !If - IsCIDR3 - !Ref CIDR3 - !Ref "AWS::NoValue" - !If - IsCIDR4 - !Ref CIDR4 - !Ref "AWS::NoValue" - !If - IsCIDR5 - !Ref CIDR5 - !Ref "AWS::NoValue" IPAddressVersion: IPV4 Name: Maintenance-WebACL-IPSet Scope: !Ref ScopeType WAFWebACL: Type: AWS::WAFv2::WebACL Properties: Name: Maintenance-WebACL DefaultAction: Block: {} Scope: !Ref ScopeType VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Ref WAFWebACLMetricName SampledRequestsEnabled: true Rules: - Name: Maintenance-WebACL-RuleIPSet Action: Allow: {} Priority: 0 Statement: IPSetReferenceStatement: Arn: !GetAtt WhiteListCIDRSet.Arn VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Sub "${WAFWebACLMetricName}-RuleIPSet" SampledRequestsEnabled: true
解説: CloudFormationテンプレートファイル(yml)
CIDR(1~5)というパラメータを用意して、ここにIPアドレスを渡してあげます(CLIからの渡し方は後述)。5個以上のIPを入れたい場合はその分増やしてください。
- Label: default: "CIDR1" Parameters: - CIDR1
CIDRパラメータに対するバリデーションです。IPアドレスの形式を正規表現で表しています。(例:123.456.789.01/32)
CIDR1: Type: String Description: White List CIDR 1 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$'
Conditionsブロックでは、各CIDRパラメータが空でないかどうかのフラグを表しています。後で出てきますが、これによってWAFで動的にIPの設定ができるようにするためのものです。
全てのフラグがfalseになると後述のWebACL設定でテンプレートバリエーションのエラーになってしまうので、便宜上一つ目のCIDR1(IsCIDR1
)では空でも空じゃなくてもtrueを返すようにしてあります。
Conditions: IsCIDR1: !Or - !Not [!Equals [!Ref CIDR1, ""]] - !Equals [!Ref CIDR1, ""] IsCIDR2: !Not [!Equals [!Ref CIDR2, ""]] IsCIDR3: !Not [!Equals [!Ref CIDR3, ""]] IsCIDR4: !Not [!Equals [!Ref CIDR4, ""]] IsCIDR5: !Not [!Equals [!Ref CIDR5, ""]]
次はWAFのWebAclに設定してあげるIPセットです。これがIPホワイトリストにあたります。
ここで、先程のConditionsで定義したIsCIDR1~IsCIDR5がtrue(空じゃない)であればIPを追加、false(空)であれば何も設定しないという分岐を、CloudFormationのIf
句によって表現しています。
「何も設定しない」というところが重要で、!Ref "AWS::NoValue"
という書き方で表現しています。
これは「何もない」「設定しない」「定義されていない」というようなAWSが提供する条件関数で、これを指定したキーは「定義されていない(≒ymlに書かれていない)」というような扱いになります。
この書き方によって、Addresses
という配列の要素数を動的に変更することが可能になり、結果的にymlを変更せずとも動的にIPリストを設定することができるわけです。
WhiteListCIDRSet: Type: AWS::WAFv2::IPSet Properties: Addresses: - !If - IsCIDR1 - !Ref CIDR1 - !Ref "AWS::NoValue" - !If - IsCIDR2 - !Ref CIDR2 - !Ref "AWS::NoValue" - !If - IsCIDR3 - !Ref CIDR3 - !Ref "AWS::NoValue" - !If - IsCIDR4 - !Ref CIDR4 - !Ref "AWS::NoValue" - !If - IsCIDR5 - !Ref CIDR5 - !Ref "AWS::NoValue" IPAddressVersion: IPV4 Name: Maintenance-WebACL-IPSet Scope: !Ref ScopeType
最後は、WAFのWebACL自体です。
WAFWebACL: Type: AWS::WAFv2::WebACL Properties: Name: Maintenance-WebACL DefaultAction: Block: {} Scope: !Ref ScopeType VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Ref WAFWebACLMetricName SampledRequestsEnabled: true Rules: - Name: Maintenance-WebACL-RuleIPSet Action: Allow: {} Priority: 0 Statement: IPSetReferenceStatement: Arn: !GetAtt WhiteListCIDRSet.Arn VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Sub "${WAFWebACLMetricName}-RuleIPSet" SampledRequestsEnabled: true
DefaultActionにBlock: {}
と書くことで、後述のRules
にマッチしない条件のときにブロックすることができます。
DefaultAction: Block: {}
こちらのRules
にはマッチ条件を書くことができ、今回使用したAWS::WAFv2::IPSet
であったり、AWSが提供しているWAFマネージドルールなどを追加することができます。
今回でいうと、定義したWhiteListCIDRSet
のIPセットにIPがあればtrueと評価され、DefaultAction
ではなくRulesのAction
が適用されます。
IPリストに当てはまる場合はアクセスを許可したいので、Allow: {}
と書くことで許可動作になります。
Rules: - Name: Maintenance-WebACL-RuleIPSet Action: Allow: {} Priority: 0 Statement: IPSetReferenceStatement: Arn: !GetAtt WhiteListCIDRSet.Arn VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Sub "${WAFWebACLMetricName}-RuleIPSet" SampledRequestsEnabled: true
IPホワイトリスト用テキストファイル(txt)
- ファイル名:iplist_waf.txt
123.456.789.10/32 234.567.890.12/32
これはちゃんとCIDR表記(/16とか/32とか)にしましょう。(WAFのIPセットではこの形でないといけない)
IPを範囲ではなく1つ1つ定義する場合は、「/32」をつけておけば大丈夫です。
先頭にコメントアウトをつけた行は、後述のシェルスクリプトの処理によって飛ばされ、行の途中に入れたコメントアウトも飛ばされます。
シェルスクリプト(bash)
- ファイル名:waf.sh
#!/bin/bash set -eu iplist_file="./iplist_waf.txt" iplist_max=5 CFN_TEMPLATE="./waf.yml" CFN_REGION="us-east-1" CFN_STACK_NAME="WafIp" CFN_WAFWebACLMetricName="WafIpWebacl" CFN_ScopeType="CLOUDFRONT" cnt=0 for ip in `cat ${iplist_file} | tr -d " \t" | grep -v "^#" | sed -e "s/\([^#]*\)#.*$/\1/g"` do if [ -n "${ip}" ]; then let cnt++ eval CFN_CIDR${cnt}="${ip}" fi if [ $cnt -ge $iplist_max ]; then echo "${iplist_max}個の制限を超えたのでここまでのIPを登録します。" echo "最後のIP: ${ip}" break fi done opt_param="" for i in `seq 1 $iplist_max` do if [ $i -gt $cnt ]; then eval CFN_CIDR${i}="" fi opt_param="${opt_param} CIDR${i}=$(eval echo '$'CFN_CIDR${i})" done aws cloudformation deploy \ --template-file ${CFN_TEMPLATE} \ --region ${CFN_REGION} \ --stack-name ${CFN_STACK_NAME} \ --no-fail-on-empty-changeset \ --parameter-overrides \ WAFWebACLMetricName=${CFN_WAFWebACLMetricName} \ ScopeType=${CFN_ScopeType} \ ${opt_param}
解説: シェルスクリプト(bash)
iplist_fileにIPホワイトリストを定義したテキストファイルのパスを定義します。
iplist_maxというのは登録できるIPの数であり、今回のymlで記述したCIDRの数に合わせてください。
iplist_file="./iplist_waf.txt" iplist_max=5
今回はCIDR5までの5つなのでiplist_max=5
ですが、例えば30個に増やしたかったらiplist_max=30
としてください。
記事で紹介するには多いと載せづらかったので絞りましたが、少なめにしてしまうとその数を超える際に結局ymlを変更する手間が発生するので、ある程度多い方がおすすめです。
その分ymlが長くなるので、「ymlの見やすさ」「将来どれくらいIPを追加するか」「CloudFormationテンプレートファイルの上限サイズ」などと比べながらご自由に設定してください。
次はymlファイルのパスやその他パラメータに使用するものです。
こちらの例はCloudFrontにアタッチするために、CFN_ScopeType="CLOUDFRONT"
とCFN_REGION="us-east-1"
を定義しています。
CloudFrontかどうか、そうでない場合どこのリージョンで使用するのか、によって変更する必要があります。
(例)東京リージョン(ap-northeast-1)のAPI Gatewayにアタッチするのであれば、CFN_ScopeType="REGIONAL"
とCFN_REGION="ap-northeast-1"
を入れる
CFN_TEMPLATE="./waf.yml" CFN_REGION="us-east-1" CFN_STACK_NAME="WafIp" CFN_WAFWebACLMetricName="WafIpWebacl" CFN_ScopeType="CLOUDFRONT"
次は、IPリストファイルを読み込んで、動的に変数を定義する方法です。
cnt=0 for ip in `cat ${iplist_file} | tr -d " \t" | grep -v "^#" | sed -e "s/\([^#]*\)#.*$/\1/g"` do if [ -n "${ip}" ]; then let cnt++ eval CFN_CIDR${cnt}="${ip}" fi if [ $cnt -ge $iplist_max ]; then echo "${iplist_max}個の制限を超えたのでここまでのIPを登録します。" echo "最後のIP: ${ip}" break fi done
for ip in `cat ${iplist_file} | tr -d " \t" | grep -v "^#" | sed -e "s/\([^#]*\)#.*$/\1/g"`
こちらの書き方は以下の処理になります。
- IPリストファイルを読み込んで
- タブを消して
#
で始まる行を消して- 途中にある
#
以降の文字列(=コメントアウト)を消す - 残った行をfor文でループする
if [ -n "${ip}" ]; then let cnt++ eval CFN_CIDR${cnt}="${ip}" fi
これはeval
を使用して、CFN_CIDR1, CFN_CIDR2, CFN_CIDR3, ...といった変数を動的に定義しIPを格納しています。後で使うcntという変数もインクリメントしておきます。
if [ $cnt -ge $iplist_max ]; then echo "${iplist_max}個の制限を超えたのでここまでのIPを登録します。" echo "最後のIP: ${ip}" break fi
これは、iplist_max以上のIPを登録させないための処理です。
次は、動的に定義したパラメータ変数をもとに、CloudFormation API(CLI)のオプション引数に渡すために整形してあげます。
if [ $i -gt $cnt ]; then
のeval
では、最初に定義したiplist_maxに満たず先程定義されなかった分の変数も空文字で定義して渡します。(定義しなくてもyml側でパラメータにdefault値を設定してあげれば問題ないのですが、ここでは一応定義しています)
そしてopt_param
という変数に、CIDR1=123.456.789.01/32 CIDR2=234.567.890.12/32
というような形の文字列に動的に展開して、オプションに渡すパラメータを作っています。
opt_param="" for i in `seq 1 $iplist_max` do if [ $i -gt $cnt ]; then eval CFN_CIDR${i}="" fi opt_param="${opt_param} CIDR${i}=$(eval echo '$'CFN_CIDR${i})" done
最後にCloudFormationのCLIの--parameter-overrides
に上記で定義したパラメータ用の変数を渡してあげます。
${opt_param}
の部分は、実際にコマンド投げるときは、CIDR1=123.456.789.01/32 CIDR2=234.567.890.12/32
というような形で渡されます。
aws cloudformation deploy \ --template-file ${CFN_TEMPLATE} \ --region ${CFN_REGION} \ --stack-name ${CFN_STACK_NAME} \ --no-fail-on-empty-changeset \ --parameter-overrides \ WAFWebACLMetricName=${CFN_WAFWebACLMetricName} \ ScopeType=${CFN_ScopeType} \ ${opt_param}
デプロイ
上記ファイルをすべて同階層において 、シェルスクリプトを実行すればデプロイが走ります。
sh waf.sh
IPリスト変更時
IPを追加したり削除したりする場合は、IPリストを変更し、再度シェルスクリプトを実行してください。
まとめ
少し長くなりましたが、ymlファイルを変えずにIPリストの増減に対応できるWAFのIP制限手法を紹介しました。
CDKを使うまででもないかなってときに便利です。