AWS WAFとCloudFormationで柔軟なIP制限をする

WAFをCloudFormationで作成してIP制限を行いたいが、IPリストの増減のたびにymlファイルを変更したくない!

ということで、それを満たすような作り方をしました。



前提

  • CDKが使えるのであればCDKの方が適している
    • 動的に追加・削除がしたいような場合は、CDKの方がマッチしています(自由な処理が書けるため)
    • そのため本記事の対象は、「CDKが使えない」「既存環境がCloudFormationで作られていてリプレースしたくない」などの方が対象です
  • IPリストの増減のたびにデプロイは必要
    • これはWAFを使って実現する以上仕方ないです
  • 不恰好な方法(yml)です
    • が、便利なので使ってます
  • 途中シェルスクリプト・デプロイコマンドが登場しますが、MacOSのPCを前提としています


※CDKで構築してみた記事も書きました。

go-to-k.hatenablog.com


登場するモノ


やりたいこと

  • CloudFormationで柔軟なIP制限が可能なWAF(WebACL)を作成する
    • -> IPを増減させてもymlファイルの変更がないようにする
  • CloudFormationのデプロイはローカルPCからシェルスクリプトaws cliによって行う


方法

CloudFormationテンプレートファイル(yml)・IPホワイトリスト用テキストファイル・シェルスクリプト(bash)を用意します。

GitHubにもあります。

github.com


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"`

こちらの書き方は以下の処理になります。

  1. IPリストファイルを読み込んで
  2. タブを消して
  3. #で始まる行を消して
  4. 途中にある#以降の文字列(=コメントアウト)を消す
  5. 残った行を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 ]; thenevalでは、最初に定義した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を使うまででもないかなってときに便利です。