AWS WAF x Terraformでサブドメインごとに異なるIP制限を行う方法と、失敗を未然に防いだ話
はじめに
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}
以外の場合
- Hostヘッダーが
工夫したポイントとしては、複数のパス設定がある場合のみ 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のクォータ制限は意外と見落としがちなので注意が必要です。
いざ制限に達して慌てる前に、設計段階においてクォータを意識しておくことが重要という教訓が得られたお話でした。
Discussion