🛡️

AWS WAF x Terraformでサブドメインごとに異なるIP制限を行う方法と、失敗を未然に防いだ話

2024/12/14に公開

はじめに

ourly株式会社でバックエンドエンジニアをしているjakeです。
弊社ではテナントごとに独自のサブドメインを割り当てたマルチテナントSaaSを運用しています。

この記事ではAWS WAFを使用した柔軟なIP制限のTerraformでの実装方法の一例と、おまけとしてAWSの狡猾な?罠にハマりかけた話をしたいと思います。

IP制限の要件と実装

ourlyで提供しているIP制限機能の要件は次の通りです。

  • 各テナントのサブドメインごとに
  • 特定のパスに対して
  • 特定IPからのみアクセスを許可する

これをTerraformで実装して問題なく運用していました。
狡猾な罠の話は後段に書くとして、まずは最終的な実装を見ていきましょう。
(簡単のため、実際の実装からは簡略化しています)

設定ファイル

IP制限の設定ファイルです。
設定の持ち方はTerraformのvariableで定義するなど様々な方法が考えられますが、ここではjsonファイルに定義しています。

sampleというドメインのテナントについて、以下のように定義しています。

  • /path_A/ または /path_B/ で始まるパスに対して
  • 203.0.113.42 からのアクセスのみを許可する

これを設定が必要なテナント分記述し、Terraformで動的にWAFのルールを生成していきます。

[
  {
    "tenant": "sample",
    "paths": [
       "^\/path_A\/",
       "^\/path_B\/"
    ],
    "allowed_ips": [
      "203.0.113.42/32"
    ]
  }
]

Terraform

IPセットの定義

まずは、許可するIPを定義するIPセットを作成します。

# IPセット (許可IP定義)
resource "aws_wafv2_ip_set" "tenant_allowed_ips" { 
  for_each           = { for x in local.tenant_allowed_ips_settings : x.tenant => x }

  provider           = aws.virginia
  scope              = "CLOUDFRONT"
  ip_address_version = "IPV4"
  addresses          = each.value.allowed_ips
}

WAF Web ACLの定義

resource "aws_wafv2_web_acl" "example_acl" {
  provider = aws.virginia
  name     = "example-acl"
  scope    = "CLOUDFRONT"

  default_action {
    allow {}
  }

  dynamic "rule" {
    for_each = local.tenant_allowed_ips_settings

    content {
      name     = "tenant-${rule.value.tenant}-block-unallowed-ip"
      priority = 10 + tonumber(rule.key)

      action {
        block {}
      }

      statement {
        and_statement {
          # Hostヘッダーのチェック
          statement {
            byte_match_statement {
              positional_constraint = "EXACTLY"
              search_string         = "${rule.value.tenant}.media.ourly.jp"

              text_transformation {
                priority = 0
                type     = "LOWERCASE"
              }

              field_to_match {
                single_header {
                  name = "host"
                }
              }
            }
          }

          # パスチェック (複数パスならor_statement)
          dynamic "statement" {
            for_each = length(rule.value.paths) > 1 ? [true] : []

            content {
              or_statement {
                dynamic "statement" {
                  for_each = rule.value.paths
                  iterator = path

                  content {
                    regex_match_statement {
                      regex_string = path.value

                      field_to_match {
                        uri_path {}
                      }

                      text_transformation {
                        priority = 0
                        type     = "NONE"
                      }
                    }
                  }
                }
              }
            }
          }

          # パスが1つの場合は直接regex_match_statement
          dynamic "statement" {
            for_each = length(rule.value.paths) == 1 ? rule.value.paths : []

            content {
              regex_match_statement {
                regex_string = statement.value

                field_to_match {
                  uri_path {}
                }
                text_transformation {
                  priority = 0
                  type     = "NONE"
                }
              }
            }
          }

          # IPチェック (許可IP以外はブロック)
          statement {
            not_statement {
              statement {
                ip_set_reference_statement {
                  arn = aws_wafv2_ip_set.tenant_allowed_ips[rule.value.tenant].arn
                }
              }
            }
          }
        }
      }

      visibility_config {
        metric_name                = "tenant-${rule.value.tenant}-block-unallowed-ip"
        cloudwatch_metrics_enabled = true
        sampled_requests_enabled   = true
      }
    }
  }
}

動的な生成があるため若干複雑ですが、要点としては以下の通りです。

  • ルールは local.tenant_allowed_ips_settings をもとにテナントごとに生成される
  • 以下の条件を満たすリクエストをブロックする
    • Hostヘッダーが ${rule.value.tenant}.media.ourly.jp である
    • パスが ${rule.value.paths} のいずれかにマッチする
    • IPが ${rule.value.allowed_ips} 以外の場合

工夫したポイントとしては、複数のパス設定がある場合のみ or_statementを使って複数の statement を生成している箇所です。

# パスチェック (複数パスならor_statement)
dynamic "statement" {
  for_each = length(rule.value.paths) > 1 ? [true] : []

  content {
    or_statement {
      dynamic "statement" {
        for_each = rule.value.paths
        iterator = path

        content {
          regex_match_statement {
            regex_string = path.value

            field_to_match {
              uri_path {}
            }

            text_transformation {
              priority = 0
              type     = "NONE"
            }
          }
        }
      }
    }
  }
}

# パスが1つの場合は直接regex_match_statement
dynamic "statement" {
  for_each = length(rule.value.paths) == 1 ? rule.value.paths : []

  content {
    regex_match_statement {
      regex_string = statement.value

      field_to_match {
        uri_path {}
      }
      text_transformation {
        priority = 0
        type     = "NONE"
      }
    }
  }
}

パスの設定が1つの場合は or_statement を使用できないため dynamic を使って条件分岐を行っています。

ちなみにもっとも単純に実装するのであれば正規表現でなく以下のような文字列マッチで十分です。
正規表現を採用した理由は、将来的な顧客の要望に合わせて柔軟な設定を可能にしたかったためです。

statement {
  byte_match_statement {
    field_to_match {
      uri_path {}
    }
    positional_constraint = "STARTS_WITH"
    search_string         = each.value
    text_transformation {
      priority = 0
      type     = "NONE"
    }
  }
}

正規表現パターンセットのクォータ

さて最終的な形は上記の通りですので以下はおまけとなり、この実装になる以前の話です。

以前の実装

以前は正規表現パターンセットを使ってテナントごとに制限したいパスの正規表現を管理していました。

resource "aws_wafv2_regex_pattern_set" "tenant_allowed_paths" {
  for_each = {for x in local.waf_ip_list : x.tenant => x}

  name  = "${each.key}-allowed-paths"
  scope = "CLOUDFRONT"

  dynamic "regular_expression" {
    for_each = each.value.paths

    content {
      regex_string = regular_expression.value
    }
  }
}

statementの実装は regex_pattern_set_reference_statement を使用して割り当てるだけなのでシンプルです。
素晴らしい。

# パスのチェック (regex_pattern_setを参照)
statement {
  regex_pattern_set_reference_statement {
    arn = aws_wafv2_regex_pattern_set.tenant_allowed_paths[rule.value.tenant].arn

    field_to_match {
      uri_path {}
    }

    text_transformation {
      priority = 0
      type     = "NONE"
    }
  }
}

問題点

これである程度の期間問題なく運用できていたのですが、ある日何気なくWAFのクォータを眺めていてはたと気づきました。

正規表現セットの最大数、10…?
天下のAWS WAFの制限数がそんなに少ないなんてある…?しかも

これらのクォータは変更できません。

という無情な一文…
半信半疑ながら検証環境で適当に正規表現パターンセットを増やしていったところ、確かに11個目でエラーが出ました。
ここでこの記事の冒頭のコードに改修を行ったわけですね。

まとめ

ここまで読んでくださりありがとうございました。
AWSのクォータ制限は意外と見落としがちなので注意が必要です。
いざ制限に達して慌てる前に、設計段階においてクォータを意識しておくことが重要という教訓が得られたお話でした。

資料

ourly tech blog

Discussion