Terraform v0.7.8でAWS WAFに対応しました

2016.11.04

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは、中山です。

2016年11月1日にTerraformのv0.7.8がリリースされました。さまざまなアップデートがありますが、AWS WAFへの対応が個人的には大きなポイントでした。早速使ってみたのでレポートします。

構成図

今回作成する構成は以下のとおりです。

tf-waf

WAFが紐付いたCloudFrontのオリジンとしてELBを作成し、後段にEC2を起動しておきます。

コード

GitHubに上げておきました。ご自由にお使いください。

WAF関連のリソース

AWS WAFを作成するためのコードとその意味を以下に記載します。なおそれぞれの情報は現時点(2016年11月4日)の情報であることに留意してください。お使いになる場合は各種ドキュメントを参照してください。

リソース名 用途
aws_waf_ipset IPマッチコンディションの作成
aws_waf_byte_match_set 文字列マッチコンディションの作成
aws_waf_size_constraint_set サイズ制約コンディションの作成
aws_waf_sql_injection_match_set SQLインジェクションマッチコンディションの作成
aws_waf_xss_match_set クロスサイト・スクリプティングマッチコンディションの作成
aws_waf_rule ルールの作成
aws_waf_web_acl Web ACLの作成

aws_waf_ipset

コードは以下のとおりです。

resource "aws_waf_ipset" "ipset" {
  name = "${var.name}IpSet"

  ip_set_descriptors {
    type  = "IPV4"
    value = "${var.ipset_config["value"]}"
  }

  ip_set_descriptors {
    type  = "IPV6"
    value = "2620:0:2d0:200::/64"
  }
}

設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。

設定 必須の有無
name IPマッチコンディションの名前。 Yes
ip_set_descriptors コンディションの条件。 No

ip_set_descriptors で指定可能な値は以下のとおりです。

設定 必須の有無
type コンディションのタイプ。 IPV4IPV6 をサポート。 Yes
value コンディションの値。IPv4アドレスかIPv6アドレスをサポート。 Yes

aws_waf_byte_match_set

コードは以下のとおりです。

resource "aws_waf_byte_match_set" "byte_set" {
  name = "${var.name}ByteSet"

  byte_match_tuples {
    text_transformation   = "LOWERCASE"
    target_string         = "test-string"
    positional_constraint = "CONTAINS"

    field_to_match {
      type = "HEADER"
      data = "user-agent"
    }
  }
}

設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。

設定 必須の有無
name 文字列マッチコンディションの名前。 Yes
byte_match_tuples コンディションの詳細。 No

byte_match_tuples で指定可能な値は以下のとおりです。

設定 必須の有無
field_to_match HTTPリクエストのどの部分にマッチさせるかを指定。 datatype を指定可能。 Yes
positional_constraint 指定した条件をどのようにマッチさせるか。 Yes
target_string マッチさせる文字列。 No
text_transformation 条件に指定した文字列をWeb ACLで評価させる前にどのように変換させるか。 Yes

aws_waf_size_constraint_set

コードは以下のとおりです。

resource "aws_waf_size_constraint_set" "size_constraint_set" {
  name = "${var.name}SizeConstraintSet"

  size_constraints {
    text_transformation = "NONE"
    comparison_operator = "GT"
    size                = "15"

    field_to_match {
      type = "HEADER"
      data = "user-agent"
    }
  }
}

設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。

設定 必須の有無
name サイズ制約コンディションの名前。 Yes
size_constraints コンディションの条件。 Yes

size_constraints で指定可能な値は以下のとおりです。

設定 必須の有無
field_to_match aws_waf_byte_match_set と同じ。 Yes
comparison_operator サイズの比較演算子。 Yes
size 条件とするサイズ。 Yes
text_transformation aws_waf_byte_match_set と同じ。 Yes

aws_waf_sql_injection_match_set

コードは以下のとおりです。

resource "aws_waf_sql_injection_match_set" "sql_injection_match_set" {
  name = "${var.name}SqlInjectionMatchSet"

  sql_injection_match_tuples {
    text_transformation = "URL_DECODE"

    field_to_match {
      type = "QUERY_STRING"
    }
  }
}

設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。

設定 必須の有無
name コンディション名。 Yes
sql_injection_match_tuples コンディションの詳細。 No

sql_injection_match_tuples で指定可能な値は以下のとおりです。

設定 必須の有無
field_to_match aws_waf_byte_match_set と同じ。 Yes
text_transformation aws_waf_byte_match_set と同じ。 Yes

aws_waf_xss_match_set

コードは以下のとおりです。

resource "aws_waf_xss_match_set" "xss_match_set" {
  name = "${var.name}XssMatchSet"

  xss_match_tuples {
    text_transformation = "NONE"

    field_to_match {
      type = "QUERY_STRING"
    }
  }
}

設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。

設定 必須の有無
name コンディション名。 Yes
xss_match_tuples コンディションの詳細。 Yes

xss_match_tuples で指定可能な値は以下のとおりです。

設定 必須の有無
field_to_match aws_waf_byte_match_set と同じ。 Yes
text_transformation aws_waf_byte_match_set と同じ。 Yes

aws_waf_rule

コードは以下のとおりです。

resource "aws_waf_rule" "ip_match_rule" {
  name        = "${var.name}IPMatchRule"
  metric_name = "${var.name}IPMatchRule"

  predicates {
    data_id = "${aws_waf_ipset.ipset.id}"
    negated = false
    type    = "IPMatch"
  }
}

設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。

設定 必須の有無
name ルールの名前。 Yes
metric_name ルールに関するCloudWatchメトリクスの名前。 Yes
predicates ルールに関連付けるコンディションの設定。 No

predicates で指定可能な値は以下のとおりです。

設定 必須の有無
data_id コンディションのID。 No
negated ルールの真偽値を反転させるかどうか。 turefalse を指定。 Yes
type コンディションのタイプ。 IPMatch / ByteMatch / SizeConstraint / SqlInjectionMatch / XssMatch を指定可能。 Yes

aws_waf_web_acl

コードは以下のとおりです。

resource "aws_waf_web_acl" "ip_match_acl" {
  name        = "${var.name}IPMatchAcl"
  metric_name = "${var.name}IPMatchAcl"

  default_action {
    type = "ALLOW"
  }

  rules {
    action {
      type = "BLOCK"
    }

    priority = 1
    rule_id  = "${aws_waf_rule.ip_match_rule.id}"
  }
}

設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。

設定 必須の有無
default_action 条件にマッチしなかった場合のデフォルトアクション。 Yes
metric_name Web ACLに関するCloudWatchメトリクスの名前。 Yes
name Web ACLの名前。 Yes
rules Web ACLにひも付けるルールの設定。 No

default_action に指定可能な値は以下のとおりです。

設定 必須の有無
type デフォルトアクションの動作。ALLOW / BLOCK / COUNT を指定。 Yes

rules に指定可能な値は以下のとおりです。

設定 必須の有無
action ルールにマッチした場合の動作。設定可能な値は default_action と同じ。 Yes
priority ルールの優先度。値が小さい方が優先される。 Yes
rule_id ひも付けるルールのID。 Yes

使ってみる

env/dev/secrets.tfvars で拒否するIPv4アドレスを指定可能にしています。適宜作成してください。以下のコマンドでTerraformを実行可能です。デフォルトではWAFとCloudFrontを紐付けていません。

$ make ENV=dev ARGS='plan -var-file=secrets.tfvars'
<snip>
$ make ENV=dev ARGS='apply -var-file=secrets.tfvars'
<snip>
app_public_ip = 54.199.179.179
cf_domain_name = d2y3jm38cbb64z.cloudfront.net
cf_id = E1EWV1B1CK4ACZ
elb_dns_name = tfWafDemo-elb-198348460.ap-northeast-1.elb.amazonaws.com
<snip>

今回テスト用に作成したCloudFrontのドメインは d2y3jm38cbb64z.cloudfront.net となっています。まずはこのドメインに対してcurlでアクセスしてみます。

$ curl http://d2y3jm38cbb64z.cloudfront.net
ip-172-16-100-82

バックエンドから正常にレスポンスが返されました。それでは本題のTerraformで作成したAWS WAFの挙動を確認したいと思います。今回は各種コンディション毎にWeb ACLを以下のように作成しました。

コンディション ひも付けたWeb ACL
IPマッチコンディション aws_waf_web_acl.ip_match_acl
文字列マッチコンディション aws_waf_web_acl.byte_match_acl
サイズ制約コンディション aws_waf_web_acl.size_constraint_acl
SQLインジェクションマッチコンディション aws_waf_web_acl.sql_injection_match_acl
クロスサイト・スクリプティングマッチコンディション aws_waf_web_acl.xss_match_acl

CloudFrontは現状1つのディストリビューション毎に1つのWeb ACLしかひも付けることができません。そのため、テストしてみたいWeb ACLを変更する場合は以下のように cloudfront.tf を修正して plan / apply してください。

--- cloudfront.tf       2016-11-03 11:59:24.000000000 +0900
+++ cloudfront.tf.orig  2016-11-03 12:06:53.000000000 +0900
@@ -3,7 +3,7 @@
   price_class      = "PriceClass_200"
   retain_on_delete = true
   enabled          = true
-  web_acl_id       = "${aws_waf_web_acl.ip_match_acl.id}"
+  web_acl_id       = "${aws_waf_web_acl.byte_match_acl.id}"

   origin {
     domain_name = "${aws_elb.elb.dns_name}"

CloudFrontに紐付いているWeb ACLは以下のコマンドで確認できます。

$ aws cloudfront get-distribution \
  --id E1EWV1B1CK4ACZ \
  --query 'Distribution.DistributionConfig.WebACLId'
"25abd9b6-ed11-4a04-b83a-2e881b6aff41"

なお、エッジサーバに変更内容が反映されるまでしばらく時間がかかります。現在のステータスは以下のコマンドで確認できます。

$ aws cloudfront get-distribution \
  --id E1EWV1B1CK4ACZ \
  --query 'Distribution.Status'
"Deployed"

IPマッチコンディションの動作

まずは、 aws_waf_ipset リソースで作成したIPマッチコンディションの動作を確認してみます。今回は特定のIPアドレスからのアクセスを拒否しているので、正常に拒否されるかどうかを確認します。

$ curl http://d2y3jm38cbb64z.cloudfront.net
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>ERROR</H1>
<H2>The request could not be satisfied.</H2>
<HR noshade size="1px">
Request blocked.
<BR clear="all">
<HR noshade size="1px">
<PRE>
Generated by cloudfront (CloudFront)
Request ID: eKBdNdIbZZVVaNmDZyJZ_W73lpGTPoGpAjbpJi_CrNHL262G8OIfdg==
</PRE>
<ADDRESS>
</ADDRESS>
</BODY></HTML>%

正常に拒否されました。コンディションの内容を確認すると指定したIPアドレスが設定されていることを確認できます。

$ aws waf get-ip-set \
  --ip-set-id "$(aws waf list-ip-sets \
    --query 'IPSets[].IPSetId' --output text)"
{
    "IPSet": {
        "IPSetId": "ea26951d-b879-4cde-b702-501dfd436804",
        "Name": "tfWafDemoIpset",
        "IPSetDescriptors": [
            {
                "Type": "IPV6",
                "Value": "2620:0000:02d0:0200:0000:0000:0000:0000/64"
            },
            {
                "Type": "IPV4",
                "Value": "***.***.**.**/**"
            }
        ]
    }
}

文字列マッチコンディションの動作

今回はユーザエージェントに含まれている文字列を小文字に変換した結果、 test-string という文字列にマッチした場合ブロックする設定にしました。 curl でユーザエージェントを指定してアクセスしてみます。

$ curl -H 'User-Agent: TEST2-STRING' http://d2y3jm38cbb64z.cloudfront.net
ip-172-16-100-82
$ curl -H 'User-Agent: TEST-STRING' http://d2y3jm38cbb64z.cloudfront.net
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>ERROR</H1>
<H2>The request could not be satisfied.</H2>
<HR noshade size="1px">
Request blocked.
<BR clear="all">
<HR noshade size="1px">
<PRE>
Generated by cloudfront (CloudFront)
Request ID: E6uafGx7LLbhecMWYFVS1y139FpQ_PlQM-YSVo5uqQDwmR8dZjW2gQ==
</PRE>
<ADDRESS>
</ADDRESS>
</BODY></HTML>%

正常に動作しているようです。文字列マッチコンディションの内容を確認すると意図した設定になっていることを確認できます。

$ aws waf get-byte-match-set \
  --byte-match-set-id "$(aws waf list-byte-match-sets \
    --query 'ByteMatchSets[].ByteMatchSetId' --output text)"
{
    "ByteMatchSet": {
        "ByteMatchSetId": "5df9a91e-f3db-4f16-b53e-4d972fb4c803",
        "Name": "tfWafDemoByteSet",
        "ByteMatchTuples": [
            {
                "TargetString": "dGVzdC1zdHJpbmc=",
                "PositionalConstraint": "CONTAINS",
                "TextTransformation": "LOWERCASE",
                "FieldToMatch": {
                    "Data": "user-agent",
                    "Type": "HEADER"
                }
            }
        ]
    }
}

サイズ制約コンディションの動作

ユーザエージェントのサイズが15byte以上の場合にブロックする設定にしました。ユーザエージェントに適当な文字列を指定して curl でアクセスしてみます。

$ curl 'http://d2y3jm38cbb64z.cloudfront.net'
ip-172-16-100-82
$ curl -H 'User-Agent: abcdefghijklmnopqrstuvwxyz' http://d2y3jm38cbb64z.cloudfront.net
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>ERROR</H1>
<H2>The request could not be satisfied.</H2>
<HR noshade size="1px">
Request blocked.
<BR clear="all">
<HR noshade size="1px">
<PRE>
Generated by cloudfront (CloudFront)
Request ID: p-5sgxx1bbr86cMutRzY07UdYDG7GMk22SNRJx_I4s21HkooqSu-aQ==
</PRE>
<ADDRESS>
</ADDRESS>
</BODY></HTML>%

正常に動作しているようです。サイズ制約コンディションの内容を確認すると意図した設定になっていることを確認できます。

$ aws waf get-size-constraint-set \
  --size-constraint-set-id "$(aws waf list-size-constraint-sets \
    --query 'SizeConstraintSets[].SizeConstraintSetId' --output text)"
{
    "SizeConstraintSet": {
        "SizeConstraints": [
            {
                "ComparisonOperator": "GT",
                "TextTransformation": "NONE",
                "FieldToMatch": {
                    "Data": "user-agent",
                    "Type": "HEADER"
                },
                "Size": 15
            }
        ],
        "SizeConstraintSetId": "baaf834a-cf9c-48f9-9a2d-e13e20fccaf3",
        "Name": "tfWafDemoSizeConstraintSet"
    }
}

SQLインジェクションマッチコンディションの動作

クエリに渡された文字列をURLデコードし、不正なSQLが検出された場合にブロックする設定にしています。今回は簡単に curl でテストしてみます。より詳細な動作確認を実施する場合はsqlmapなどを利用してください。

$ curl 'http://d2y3jm38cbb64z.cloudfront.net/index.html?=id1'
ip-172-16-100-82
$ curl 'http://d2y3jm38cbb64z.cloudfront.net/index.html?id=1%20UNION%20ALL%20SELECT%20NULL--%20'
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>ERROR</H1>
<H2>The request could not be satisfied.</H2>
<HR noshade size="1px">
Request blocked.
<BR clear="all">
<HR noshade size="1px">
<PRE>
Generated by cloudfront (CloudFront)
Request ID: 8kd8EtTftVaOIVnw_U0Z-K94IsBly2nUHHTDHFFfUkY7pZIDYxETKQ==
</PRE>
<ADDRESS>
</ADDRESS>
</BODY></HTML>%

ブロックされました。SQLインジェクションマッチコンディションの内容を確認してみます。

$ aws waf get-sql-injection-match-set \
  --sql-injection-match-set-id "$(aws waf list-sql-injection-match-sets \
    --query 'SqlInjectionMatchSets[].SqlInjectionMatchSetId' --output text)"
{
    "SqlInjectionMatchSet": {
        "SqlInjectionMatchTuples": [
            {
                "TextTransformation": "URL_DECODE",
                "FieldToMatch": {
                    "Type": "QUERY_STRING"
                }
            }
        ],
        "Name": "tfWafDemoSqlInjectionMatchSet",
        "SqlInjectionMatchSetId": "75903417-bc48-4028-9e79-e4cc14d3de92"
    }
}

クロスサイト・スクリプティングマッチコンディションの動作

クエリに渡された文字列が不正だと判断されたらブロックする設定にしています。こちらのエントリに記載されているクエリ文字列を参考にしてアクセスしてみます。

$ curl 'http://d2y3jm38cbb64z.cloudfront.net/'
ip-172-16-100-82
$ curl 'http://d2y3jm38cbb64z.cloudfront.net/?<SCRIPT>alert(“Cookie”+document.cookie)</SCRIPT>'
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>ERROR</H1>
<H2>The request could not be satisfied.</H2>
<HR noshade size="1px">
Request blocked.
<BR clear="all">
<HR noshade size="1px">
<PRE>
Generated by cloudfront (CloudFront)
Request ID: ECFcBiTzsO4wa1iAn1Raq0oCk55cbxFf6DxJoAfmRzKVyN1_jmi-Cg==
</PRE>
<ADDRESS>
</ADDRESS>
</BODY></HTML>%

ブロックされたようです。設定内容を確認してみます。

$ aws waf get-xss-match-set \
  --xss-match-set-id "$(aws waf list-xss-match-sets \
    --query 'XssMatchSets[].XssMatchSetId' --output text)"
{
    "XssMatchSet": {
        "XssMatchTuples": [
            {
                "TextTransformation": "NONE",
                "FieldToMatch": {
                    "Type": "QUERY_STRING"
                }
            }
        ],
        "XssMatchSetId": "3db6a2da-d144-402e-9af9-7f486c9381be",
        "Name": "tfWafDemoXssMatchSet"
    }
}

まとめ

いかがだったでしょうか。

AWS WAF自体がシンプルなため、それを利用する各種リソースも比較的簡単な設定になっています。ただし、まだできたてホヤホヤなのでバグがあるようですが、今後に期待したいと思います。

本エントリがみなさんの参考になれば幸いです。