CloudFront+WAF+Secrets ManagerでALBへの直接アクセスを防ぐ方法
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の中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
北海道の駅巡りコーナー