🧇

AWS WAF を使って IP アドレスによるアクセス許可と BASIC 認証を組み合わせる

2023/10/20に公開

はじめに

AWS WAF は CloudFront や ALB にアタッチすることができる Firewall サービスです。
Web ACL (Access Control List) を定義することで、特定の条件にマッチしたアクセスをブロックしたり許可することができます。

インターネットに公開するサービスを新規に開発している段階において、アクセス可能な人や場所を限定したいことはよくあります。

この記事では、AWS WAF を利用して特定のIPアドレス(CIDR)によるアクセス許可と、BASIC認証によるアクセス許可を組み合わせる方法を共有します。

要件

  • 許可されたIPアドレス(CIDR)にマッチしたらアクセスを許可する
  • リクエストのヘッダに正しいBASIC認証情報を含む場合はアクセスを許可する
  • 上記以外のアクセスはブロックする

仕組みの概要

以下に概要を示します

  • デフォルトは「アクセス許可(allow)」とする
    • サービスOpenするとき、ruleを削除するだけでアクセス制限が外れることを想定している
  • 以下に該当する(AND)場合はアクセスをblockする
    • 許可されたIPアドレスではない
    • BASIC認証情報を持っていない
  • アクセスをblockする際、カスタムレスポンスでBASIC認証を要求する (WWW-Authenticateヘッダを返却する)

Terraformのコード

先にコードを掲載します。

modules/waf/variables.tf
// モジュールのパラメタ
variable "allowed_ip_list" {}
variable "basic_auth" {
  type = object({
    user     = string
    password = string
  })
}
modules/waf/main.tf
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      configuration_aliases = [aws.virginia]
    }
  }
}

locals {
  // 期待するAuthorization ヘッダーの値をあらかじめ計算しておく
  credential = base64encode("${var.basic_auth.user}:${var.basic_auth.password}")
}

resource "aws_wafv2_web_acl" "main" {
  provider    = aws.virginia
  name        = "example-web-acl"
  description = "Web ACL example"
  scope       = "CLOUDFRONT"

  // デフォルトのアクションを許可(allow)とする
  default_action {
    allow {}
  }

  // BASIC認証
  rule {
    name     = "basic-auth"
    priority = 20

    action {
      block {
        // 後述の条件に該当する場合のアクションを block とする
        // また、その際カスタムレスポンスとして、BASIC認証によるユーザ名と
        // パスワードの入力を要求するレスポンスを返す
        custom_response {
          response_code = 401
          response_header {
            name  = "WWW-Authenticate"
            value = "Basic realm=\"Secure Area\""
          }
        }
      }
    }
    // Block の条件 (AND) :
    statement {
      and_statement {
        statement {
          // 条件1
          // アクセス元のIPアドレスが許可IPリストに含まれていない
          not_statement {
            statement {
              ip_set_reference_statement {
                arn = aws_wafv2_ip_set.allowed_ips.arn
              }
            }
          }
        }
        statement {
          // 条件2
          // Authorization ヘッダーに
          not_statement {
            statement {
              byte_match_statement {
                positional_constraint = "EXACTLY"
                search_string         = "Basic ${local.credential}"
                field_to_match {
                  single_header {
                    name = "authorization"
                  }
                }
                text_transformation {
                  priority = 0
                  type     = "NONE"
                }
              }
            }
          }
        }
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "example-basic-auth"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "example-web-acl"
    sampled_requests_enabled   = true
  }
}

//アクセスを許可する IPアドレスのセット
resource "aws_wafv2_ip_set" "allowed_ips" {
  provider           = aws.virginia
  name               = "example-allowed-ips"
  description        = "Authorized IP addresses"
  scope              = "CLOUDFRONT"
  ip_address_version = "IPV4"
  addresses          = var.allowed_ip_list
}

このコードについて以下で解説します。

IPアドレスによるアクセス制御の設定

AWS WAFにおいてIPアドレスによるアクセス制御を行うには、まずIPセットを用意します。

//アクセスを許可する IPアドレスのセット
resource "aws_wafv2_ip_set" "allowed_ips" {
  provider           = aws.virginia
  name               = "example-allowed-ips"
  description        = "Authorized IP addresses"
  scope              = "CLOUDFRONT"
  ip_address_version = "IPV4"
  addresses          = var.allowed_ip_list
}

上記ではIPアドレスのリストは var.allowed_ip_list に格納されていて、モジュールを利用する側で以下のような形でパラメタとして渡すことを想定しています。

module "waf" {
  src = "../modules/waf"
  allowed_ip_list = [
    "xxx.xxx.xxx.xxx/32",
    "yyy.yyy.yyy.yyy/32",
    "zzz.zzz.zzz.zzz/32"
  ]
  ... 
}

BASIC認証の設定

以下のコードでは、BASIC認証ヘッダに格納されていることを期待している文字列をあらかじめ求めています。

locals {
  // 期待するAuthorization ヘッダーの値をあらかじめ計算しておく
  credential = base64encode("${var.basic_auth.user}:${var.basic_auth.password}")
}

そして以下のブロックでその値を参照して、リクエストのAuthorizationヘッダーに該当する文字列が格納されていることをチェックします。

              byte_match_statement {
                positional_constraint = "EXACTLY"
                search_string         = "Basic ${local.credential}"
                field_to_match {
                  single_header {
                    name = "authorization"
                  }
                }
                text_transformation {
                  priority = 0
                  type     = "NONE"
                }
              }

条件にマッチしない場合は、以下のブロックでカスタムレスポンスを返却するよう設定してます。

    action {
      block {
        // 後述の条件に該当する場合のアクションを block とする
        // また、その際カスタムレスポンスとして、BASIC認証によるユーザ名と
        // パスワードの入力を要求するレスポンスを返す
        custom_response {
          response_code = 401
          response_header {
            name  = "WWW-Authenticate"
            value = "Basic realm=\"Secure Area\""
          }
        }
      }
    }

カスタムレスポンスでは WWW-Authenticate レスポンスヘッダでアクセス元に対してBASIC認証を要求しています。一般的にブラウザがこのレスポンスを受け取ると、BASIC認証情報(User/Password)の入力を促すダイアログを表示します。

参考: WWW-Authenticate - HTTP | MDN

IPアドレスとBASIC認証の組み合わせ

前述の条件を組み合わせると以下のようなルールとなります。(構造を見やすくするため詳細は省略しています)

rule {
  // 後続の statement にマッチした場合、アクセスをブロックしてカスタムレスポンスを返却する
  action {
    block {
      // カスタムレスポンスでBASIC認証を要求する
    }
  }
  statement {
    and_statement {
      // アクセス元のIPアドレスが許可IPリストに含まれていない
      statement {
        // ここで条件を反転
        not_statement {
	  statement {
	    // IPアドレスを許可IPリストとマッチしているか?
	  }
	}
      }
      // Authrization ヘッダーにBASIC認証情報が含まれていない
      statement {
        // ここで条件を反転
        not_statement {
	  statement {
	    // Authorization ヘッダーにBASIC認証情報が含まれている
	  }
	}
      }
    }
  }
}

注意点として、条件の否定(not_statement)を直接 rule ブロックや and_statement の直下に記述できないため、statementブロックでラップし直す必要があり、やや分かりづらい構造になっています。

以上をまとめると、先にあげたようなコード になります。

まとめ

AWS WAF を利用して特定のユーザーに対してサービスへのアクセスを許可する方法として、IPアドレスによる許可とBASIC認証を組み合わせる方法を紹介しました。

IPアドレスによる厳格なアクセス制御と、BASIC認証による緩いアクセス制御を組み合わせることで一時的にアクセスを許可したいケースにも柔軟に対応できると思います。

1点注意があるとすると、BASIC認証以外の認証方式(OAuthなど)を必要としているAPIなどに適用しようとするとAPIを利用するクライアント側になにかしらの修正が必要となり、アプリケーション層にインフラ層の関心事を持ち込むことになる点が挙げられます。
すでに認証かかっている箇所に二重に認証をかける必要は無い、という判断でそのようなAPIにはこのルールの対象から除外するというのもアリだとは思います(リスクとの兼ね合いだと思います)

この記事が誰かの役に立つと幸いです。

参考ページなど

https://zenn.dev/trkdkjm/articles/41bb1f82a6e7b4
https://developer.hashicorp.com/terraform/language/functions/base64encode
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate

Discussion