こしあん
2025-01-26

CloudFront+WAF+Secrets ManagerでALBへの直接アクセスを防ぐ方法


31{icon} {views}

CloudFrontからのみカスタムヘッダーを付与し、WAFがSecrets Managerの値と一致しないリクエストをブロックする構成です。ヘッダーをローテーションすることで、固定値の推測攻撃も防止できます。

はじめに

  • SAP勉強していたら出てきたパターンで、汎用的に使えそうだったやつ。Public ALBを使っているとALBのドメインからWebサーバーに接続できてしまう(今はVPCオリジンできるけど)
  • ALB単独にはアクセスさせずに、CloudFrontからのアクセスのみ通す場合は、CloudFrontから任意のヘッダーを付与することが手っ取り早い。S3のOACのような便利な仕組みはALBだとない
  • ただ固定のヘッダーを付与し続けると推測されてしまうので、ヘッダーをローテーションする仕組みが必要。そこにSecrets Managerが出てくるというもの

アーキテクチャー

作るアーキテクチャーはこの通り。

  • 前段にCloudFrontをおく
  • CloudFrontでx-custom-headerというカスタムヘッダーを付与する
    • このとき付与する値をSecrets Managerから取ってくる
  • ALB側にWAFv2を付与し、Secrets Managerの値と一致しなければブロックするというカスタムルールを追加する
    • これによってオリジン単独のアクセスをブロックする

Secrets Managerの値を更新した場合はどうなるのか?

→基本的には自動反映する仕組みはない。IaC(Terraformなど)でapplyすると最新の値が同期される。もしここをシームレスに移行したければ、Lambdaを別途定義するでも良い。

結果

CloudFrontからアクセスしたとき→アクセス可能

ALB単独でアクセスしたとき→「403 Forbidden」となる(WAFでブロックした結果)

シークレットを変えてみる

シークレットのローテーションをSecrets Managerをマネジメントコンソールから編集することで擬似的に再現する。

ステージングラベルでAWSCURRENTというのがついているが、これはAWSが自動的に付与したもの。Terraformで最新のシークレットを取るときに使う。

terraform applyすると、CloudFrontとWAFの部分の更新が入る。実際に更新後のWAFルールを見ると、更新後のシークレット値になっている。

ALB単独の攻撃は結構ある

WAFログ見てて発見したのは、結構botがALBに攻撃を仕掛けている。デバッグのために自分がアクセスしたのは10ぐらいで、この記事を書いている(2~3時間)裏に100以上のブロックが発生している。WAFのログを見ると、アメリカと中国からのBotのようだ。だいぶWAFにホイホイされている。

おそらくではあるが、ALBのデフォルトのドメイン名が簡単なURLすぎて、HTTPなのでBotが総当りの攻撃をしやすいのだと思う。こういうURLになっている

http://alb-1234567890.(AWS-REGION).elb.amazonaws.com/

10桁の数字なので総当たり攻撃をしやすいのだと思う。これがCloudFrontだと、13桁の小文字+数字なので総当たりのパターンが多い。

https://a0b1c2d3e4f5g.cloudfront.net/

実際にパターン数を比較すると、$36^{13}=1.7\times 10^{20}$で、$10^{10}$よりも全然多い。自分が試した以外には特に見ていなかった。

実装のポイント

Terraformでの実装のポイントは以下の通り。

Secrets Manager

シークレットの登録は普通にTerraformで行う(ランダムな初期値を使う)。ここまでは普通通り

resource "aws_secretsmanager_secret" "custom_header_secret" {
  name        = "my-custom-header-secret"
  description = "Secret value used in CloudFront custom header and verified by WAF"
  recovery_window_in_days = 0
  # rotation_lambda_arn = aws_lambda_function.rotation.arn
  # rotation_rules {
  #   automatically_after_days = 30
  # }
}

# ランダム文字列を生成して Secret のバージョンとして登録
resource "random_string" "custom_header_random_value" {
  length  = 16
  special = false
}

resource "aws_secretsmanager_secret_version" "custom_header_secret_version" {
  secret_id     = aws_secretsmanager_secret.custom_header_secret.id
  secret_string = random_string.custom_header_random_value.result
}

しかし、WAFとCloudFrontのカスタムヘッダーに参照するときは、dataブロックを使う。これによって最新のシークレット値が伝播されるようにする。マネジメントコンソールのように、IaCの外側から最新のシークレット値が変えられたときに有効。

data "aws_secretsmanager_secret_version" "current_custom_header" {
  secret_id     = aws_secretsmanager_secret.custom_header_secret.id
  version_stage = "AWSCURRENT" # 必要に応じて指定
  depends_on    = [aws_secretsmanager_secret_version.custom_header_secret_version]
}

CloudFront

CloudFront側でカスタムヘッダーを設定するのはオリジンで行う。ここではSSLの設定している。

  # ALB をオリジンに設定
  origin {
    domain_name = aws_lb.alb.dns_name
    origin_id   = "alb-origin"

    # HTTP で ALB に接続する
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "http-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }

    custom_header {
      name  = "x-custom-header"
      value = data.aws_secretsmanager_secret_version.current_custom_header.secret_string
    }
  }

WAF

WAF側のルールはコツがいて、IAMポリシーと同じで「指定した値とカスタムヘッダーが一致しなかったらブロック」という書き方

  rule {
    name     = "check-custom-header"
    priority = 1

    statement {
      not_statement {
        statement {
          byte_match_statement {
            field_to_match {
              single_header {
                name = "x-custom-header"
              }
            }
            positional_constraint = "EXACTLY"
            # Secrets Manager から取得した値をそのまま使う
            search_string = data.aws_secretsmanager_secret_version.current_custom_header.secret_string

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

    action {
      block {}
    }

    visibility_config {
      sampled_requests_enabled   = true
      cloudwatch_metrics_enabled = true
      metric_name                = "check-custom-header"
    }
  }

全体コード

以下の2コード

  • cloudfront.tf
  • ec2_alb.tf

cloudfront.tf

# 1. Secrets Manager にシークレットを用意(例ではランダム文字列を格納)
#    - 自動ローテーションを有効にする場合は別途 rotation_enabled や
#      rotation_lambda_arn の指定が必要です。
#    - ここでは単純化のため省略しています。

resource "aws_secretsmanager_secret" "custom_header_secret" {
  name                    = "my-custom-header-secret"
  description             = "Secret value used in CloudFront custom header and verified by WAF"
  recovery_window_in_days = 0
  # rotation_lambda_arn = aws_lambda_function.rotation.arn
  # rotation_rules {
  #   automatically_after_days = 30
  # }
}

# ランダム文字列を生成して Secret のバージョンとして登録
resource "random_string" "custom_header_random_value" {
  length  = 16
  special = false
}

resource "aws_secretsmanager_secret_version" "custom_header_secret_version" {
  secret_id     = aws_secretsmanager_secret.custom_header_secret.id
  secret_string = random_string.custom_header_random_value.result
}


# 3. WAFv2 の Web ACL を作成し、ALB に関連付ける
#    - X-Custom-Header に対して Byte Match Statement で一致しなければブロックする。
#    - search_string に Secrets Manager の値を参照することで、該当しないリクエストをブロックする。

data "aws_secretsmanager_secret_version" "current_custom_header" {
  secret_id     = aws_secretsmanager_secret.custom_header_secret.id
  version_stage = "AWSCURRENT" # 必要に応じて指定
  depends_on    = [aws_secretsmanager_secret_version.custom_header_secret_version]
}

resource "aws_wafv2_web_acl" "example_waf" {
  name        = "example-waf"
  description = "WAF that verifies custom header"
  scope       = "REGIONAL"

  default_action {
    allow {}
  }

  rule {
    name     = "check-custom-header"
    priority = 1

    statement {
      not_statement {
        statement {
          byte_match_statement {
            field_to_match {
              single_header {
                name = "x-custom-header"
              }
            }
            positional_constraint = "EXACTLY"
            # Secrets Manager から取得した値をそのまま使う
            search_string = data.aws_secretsmanager_secret_version.current_custom_header.secret_string

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

    action {
      block {}
    }

    visibility_config {
      sampled_requests_enabled   = true
      cloudwatch_metrics_enabled = true
      metric_name                = "check-custom-header"
    }
  }

  visibility_config {
    sampled_requests_enabled   = true
    cloudwatch_metrics_enabled = true
    metric_name                = "example-waf"
  }
}

resource "aws_wafv2_web_acl_association" "alb_association" {
  resource_arn = aws_lb.alb.arn
  web_acl_arn  = aws_wafv2_web_acl.example_waf.arn
}

# 4. CloudFront ディストリビューションを作り、ALB をオリジンに設定
#    - custom_header で Secrets Manager の値を付与。
#    - 実際にはビヘイビアやキャッシュ設定、ACM 証明書などの設定が必要です。


# 9. CloudFront ディストリビューション
#    ALB の DNS をオリジンとして設定し、CloudFrontデフォルト証明書 (*.cloudfront.net) で HTTPS 化。
resource "aws_cloudfront_distribution" "cf" {
  enabled         = true
  is_ipv6_enabled = true
  comment         = "CloudFront distribution with default SSL for ALB origin"

  # ALB をオリジンに設定
  origin {
    domain_name = aws_lb.alb.dns_name
    origin_id   = "alb-origin"

    # HTTP で ALB に接続する
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "http-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }

    custom_header {
      name  = "x-custom-header"
      value = data.aws_secretsmanager_secret_version.current_custom_header.secret_string
    }
  }

  # デフォルト挙動
  default_cache_behavior {
    target_origin_id       = "alb-origin"
    viewer_protocol_policy = "redirect-to-https" # HTTPならHTTPSへリダイレクト
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]

    min_ttl     = 0
    default_ttl = 300
    max_ttl     = 3600

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  # CloudFront のデフォルト証明書で HTTPS
  viewer_certificate {
    cloudfront_default_certificate = true
    minimum_protocol_version       = "TLSv1.2_2019"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}

output "cloudfront_domain_name" {
  value = aws_cloudfront_distribution.cf.domain_name
}

output "alb_domain_name" {
  value = aws_lb.alb.dns_name
}

ec2_alb.tf

# 1. EC2 用 IAM ロール・ポリシー (Session Manager 用)
resource "aws_iam_role" "ec2_ssm_role" {
  name               = "ec2_ssm_role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role_policy.json
}

data "aws_iam_policy_document" "ec2_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role_policy_attachment" "ec2_ssm_attach" {
  role       = aws_iam_role.ec2_ssm_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# 2. EC2 用セキュリティグループ (ALB からの 80 ポートのみ受け)
resource "aws_security_group" "web_sg" {
  name   = "web-sg"
  vpc_id = var.vpc_id

  ingress {
    description     = "Allow HTTP from ALB"
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id]
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = -1
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}

# 3. EC2 インスタンスプロファイル
resource "aws_iam_instance_profile" "ec2_ssm_profile" {
  name = "ec2-ssm-profile"
  role = aws_iam_role.ec2_ssm_role.name
}

# 4. EC2 インスタンス (Amazon Linux 2023) の作成
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["137112412989"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023*-kernel-*-x86_64"]
  }

  filter {
    name   = "architecture"
    values = ["x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "web" {
  ami                  = data.aws_ami.amazon_linux.image_id
  instance_type        = "t3.micro"
  subnet_id            = element(var.private_subnets, 0)
  security_groups      = [aws_security_group.web_sg.id]
  iam_instance_profile = aws_iam_instance_profile.ec2_ssm_profile.name

  user_data = <<-EOF
    #!/bin/bash
    dnf update -y
    dnf install -y httpd
    systemctl enable httpd
    systemctl start httpd

    echo "<h1>Hello from web server.</h1>" > /var/www/html/index.html
  EOF

  tags = {
    Name = "web-primary"
  }
}

# 5. ALB 用セキュリティグループ (80, 443 をインターネットに開放)
resource "aws_security_group" "alb_sg" {
  name   = "alb-sg"
  vpc_id = var.vpc_id

  ingress {
    description      = "Allow HTTP"
    from_port        = 80
    to_port          = 80
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  ingress {
    description      = "Allow HTTPS"
    from_port        = 443
    to_port          = 443
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = -1
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}

# 6. アプリケーションロードバランサ (Public Subnet) の作成
resource "aws_lb" "alb" {
  name               = "alb"
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id]
  subnets            = var.public_subnets

  tags = {
    Name = "alb"
  }
}

# 7. ターゲットグループ & EC2 アタッチ
resource "aws_lb_target_group" "tg" {
  name        = "tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "instance"

  health_check {
    path = "/"
  }
}

resource "aws_lb_target_group_attachment" "web_primary" {
  target_group_arn = aws_lb_target_group.tg.arn
  target_id        = aws_instance.web.id
  port             = 80
}

# 8. ALB リスナー (HTTP)。HTTPS リスナーは使わず、ALB は HTTP のみ。
#    受けたリクエストをそのままターゲットグループにフォワードする
resource "aws_lb_listener" "http_listener" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg.arn
  }
}

所感

  • VPCオリジンできたからこれやる必要はないかもしれないが、デフォルトのALBって結構攻撃されるんだなって知ったのが発見
  • IaCとは相性良さそう


Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です