AWSのWAFとCloudFrontをTerraformで導入してみました
以前は以下のようにAWSにおける踏み台の構成を2つ考えて、Terraformで構築してみました。
今回は引き続き以下クラスメソッド様が公開してくれている「2021年版 AWSセキュリティ対策全部盛り 初級から上級まで」の初級編で紹介されていた WAFとCloudFrontを経由して ALB配下で動作しているアプリケーション及び静的なファイルを配置しているS3にアクセスが届くように構築してみました。
ブログ版
Yotube版
概要
構成図は以下のような形になります。
- まずWAF経由でパスされた通信のみCloudFrontに届く
- CloudFrontで静的なコンテンツを示すパス /static/* に関してはS3をOriginとする
- その他のパスは ALBをOriginとする
- ALB・S3のコンテンツはCloudFrontを経由したアクセスのみ許可する
- 直接ALBやS3のドメイン名を指定してアクセスさせない
この構成のメリット
- まずWAFで怪しいリクエストを拒否できる
- ALBに到達する前にCloudFrontを経由するのでDDOS攻撃に強い
- 以下参考サイト(AWS公式)をご参照ください
【参考】
この構成で考慮すべきポイント
- WAFのWebACLにどのようなルールを設定するか
- 今回はAWSが用意してくれているマネージドルールのみを採用
- サービスの種類によって、あまりルールを適用できないケースもあるか
- CloudFrontのキャッシュ戦略
実装概要
一部のポイントとなりそうなコードのみをZennには掲載しますので、コード全文がみたい場合はこちらのリポジトリをご参照ください。
事前準備
CloudFront・ALBに適用するドメインを取得する必要があります。
今回では hoge.com
というドメインを取得していて
CloudFront: cdn.hoge.com
ALB: alb.hoge.com
というサブドメインを割り当てる前提で構築しております。
Route53で独自ドメインを取得すれば手早いですが、今回は別のドメイン取得サービスで取得したドメインを使っていて、NameServerにRoute53を利用して各種DNSレコードを作成しております。
ALB・S3のリソース作成
事前準備で記載したように、CloudFront・ALBに適用するドメインのためにACMで証明書を発行します
provider "aws" {
alias = "virginia"
region = "us-east-1"
}
# 証明書発行リクエスト
resource "aws_acm_certificate" "cert" {
domain_name = "*.${var.root_domain}"
subject_alternative_names = [var.root_domain]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_acm_certificate" "cert_cloudfront" {
domain_name = "cdn.${var.root_domain}"
validation_method = "DNS"
provider = aws.virginia
lifecycle {
create_before_destroy = true
}
}
また、ACMで証明書を発行する際に本当にドメインの所有者か検証が必要です。
今回は検証方法に「AWSが指定するDNSレコードを設定する方式」を選んでいるため、以下のように検証用のRoute53のレコードを作成しております。
# ACMのドメイン検証
resource "aws_route53_record" "cert_validation_main" {
for_each = {
for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
type = each.value.type
ttl = "300"
zone_id = var.host_zone_id
}
# cert_cloudfrontも同様に検証
ALBリソースの作成の際には↑で作成したACMの証明書のARNを指定する必要があります
resource "aws_lb" "app" {
name = "${var.prefix}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.ingress_all.id]
subnets = var.alb_subnets
# テストようなので保護機能を有効にしない
enable_deletion_protection = false
}
resource "aws_lb_listener" "http_blue" {
load_balancer_arn = aws_lb.app.arn
port = "80"
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
tags = merge({ "Name" : "${var.prefix}-blue" }, var.tags)
}
resource "aws_lb_listener" "https_blue" {
load_balancer_arn = aws_lb.app.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
# NOTE: ACMで作成した署名書のARN
certificate_arn = var.acm_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app_blue.arn
}
}
resource "aws_lb_target_group" "app_blue" {
name = "${var.prefix}-tg-blue"
deregistration_delay = 60
port = 8080
protocol = "HTTP"
target_type = "ip"
vpc_id = var.vpc_id
health_check {
healthy_threshold = 2
interval = 30
matcher = 200
path = "/"
port = "traffic-port"
protocol = "HTTP"
timeout = 5
unhealthy_threshold = 2
}
}
最低限の対策を入れつつ、S3バケットを作成します
resource "aws_s3_bucket" "static" {
bucket = "${var.prefix}-static"
}
resource "aws_s3_bucket_versioning" "static" {
bucket = aws_s3_bucket.static.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "static" {
bucket = aws_s3_bucket.static.bucket
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "static" {
bucket = aws_s3_bucket.static.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# CloudFront経由のアクセスに制限
resource "aws_s3_bucket_policy" "static" {
bucket = aws_s3_bucket.static.id
policy = templatefile("${path.module}/policy/bucket_policy.json", {
OAI = aws_cloudfront_origin_access_identity.static.iam_arn
BUCKET_ARN = aws_s3_bucket.static.arn
})
}
# テスト用にファイルを配置
resource "aws_s3_object" "object" {
bucket = aws_s3_bucket.static.id
key = "static/test.json"
source = "${path.module}/object/test.json"
}
なおS3のバケットポリシーは以下のようなテンプレートを利用して、CloudFront経由でのみアクセスをさせるようにOAIからのアクセスのみ許容しております。
{
"Statement": {
"Sid": "1",
"Effect": "Allow",
"Principal": {
"AWS": "${OAI}"
},
"Action": "s3:GetObject",
"Resource": "${BUCKET_ARN}/*"
}
}
CloudFrontリソースの作成
CloudFrontのリソースを以下のように作成します。
# Cloudfront
resource "aws_cloudfront_distribution" "asset" {
enabled = true
aliases = [
"cdn.${var.root_domain}"
]
# NOTE: WAFのWebACLの適用
web_acl_id = aws_wafv2_web_acl.main.arn
# NOTE: Origin設定(ALB)
origin {
domain_name = "alb.${var.root_domain}"
origin_id = aws_lb.app.id
custom_origin_config {
http_port = 80
https_port = 443
origin_keepalive_timeout = 5
origin_protocol_policy = "https-only"
origin_read_timeout = 60
origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"]
}
}
# NOTE: Origin設定(S3)
origin {
domain_name = aws_s3_bucket.static.bucket_regional_domain_name
origin_id = aws_s3_bucket.static.id
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.static.cloudfront_access_identity_path
}
}
viewer_certificate {
cloudfront_default_certificate = false
acm_certificate_arn = var.cloudfront_acm_arn
minimum_protocol_version = "TLSv1"
ssl_support_method = "sni-only"
}
# ALBのキャッシュ戦略
default_cache_behavior {
allowed_methods = ["HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"]
cached_methods = ["HEAD", "GET", "OPTIONS"]
target_origin_id = aws_lb.app.id
forwarded_values {
query_string = true
cookies {
forward = "all"
}
headers = ["*"]
/* headers = ["Accept", "Accept-Language", "Authorization", "CloudFront-Forwarded-Proto", "Host", "Origin", "Referer", "User-agent"] */
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 10
max_ttl = 60
}
# S3のキャッシュ戦略
ordered_cache_behavior {
path_pattern = "/static/*"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = aws_s3_bucket.static.id
forwarded_values {
query_string = false
headers = ["Origin"]
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 300
max_ttl = 600
compress = true
viewer_protocol_policy = "redirect-to-https"
}
restrictions {
geo_restriction {
restriction_type = "whitelist"
locations = ["JP"]
}
}
}
resource "aws_cloudfront_origin_access_identity" "static" {}
WAFのWebACLは以下のように作成しました。
WAFのリソース作成
とりあえず無料で利用できるAWSのマネージドルールを詰め込んだらコードが縦に長くなってしまいました。
resource "aws_wafv2_web_acl" "main" {
name = "${var.prefix}-app-acl"
description = "Web ACL for ${var.prefix}-app"
scope = "CLOUDFRONT"
provider = aws.virginia
default_action {
allow {}
}
# 無料で使えるManagedRuleを追加
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 10
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesCommonRuleSetMetric"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesKnownBadInputsRuleSet"
priority = 20
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesKnownBadInputsRuleSetMetric"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesAmazonIpReputationList"
priority = 30
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesAmazonIpReputationList"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesAmazonIpReputationListMetric"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesAnonymousIpList"
priority = 40
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesAnonymousIpList"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesAnonymousIpListMetric"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesSQLiRuleSet"
priority = 50
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesSQLiRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesSQLiRuleSetMetric"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesLinuxRuleSet"
priority = 60
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesLinuxRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesLinuxRuleSetMetric"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesUnixRuleSet"
priority = 70
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesUnixRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesUnixRuleSetMetric"
sampled_requests_enabled = false
}
}
rule {
name = "AWSRateBasedRule"
priority = 1
action {
block {}
}
statement {
rate_based_statement {
limit = 500
aggregate_key_type = "IP"
scope_down_statement {
geo_match_statement {
country_codes = ["US", "NL"]
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSRateBasedRuleMetric"
sampled_requests_enabled = false
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "TerraformWebACLMetric"
sampled_requests_enabled = false
}
}
# NOTE: アクセス数がそれなりのサービスだとCloudWatchLogsに出力するとコストが嵩む
# Firehose経由でS3への配信も考慮が必要
resource "aws_wafv2_web_acl_logging_configuration" "main" {
log_destination_configs = [aws_cloudwatch_log_group.waf.arn]
provider = aws.virginia
resource_arn = aws_wafv2_web_acl.main.arn
redacted_fields {
uri_path {}
}
}
動作確認
以下コマンドで動作確認をしていきます。
WAFが適用されているか確認
# これならステータスコード200でレスポンス
curl --location --request GET 'https://cdn.hoge.com' \
--header 'User-Agent: test'
# これはステータスコード403でレスポンス(User-Agentが空)
curl --location --request GET 'https://cdn.hoge.com' -H 'User-Agent: '
ALBへのアクセスはCloudFront経由でしかできないことを確認
# CloudFront経由はアクセスできる
curl --location --request GET 'https://cdn.hoge.com' \
--header 'User-Agent: test'
# ALBのドメイン名直はアクセスできない
curl --location --request GET 'https://alb.hoge.com' \
--header 'User-Agent: test'
S3へのアクセスはCDN経由でしかできないことを確認
# CloudFront経由はアクセスできる
curl --location --request GET https://hoge.com/static/test.json
# S3直接はアクセスできない
curl --location --request GET https://${S3のDNS名}/static/test.json
ブラウザでも以下のようにCDNのドメイン名を指定したアクセスはできます。
こちらはALBの配下で稼働しているテスト用のアプリケーションのコンテンツを取得できている時に表示されるHTMLです。
https://cdn.hoge.dom
へアクセス
続いて https://cdn.hoge.com/static/test.json
にアクセスするとこちらのファイルを取得できます。
{
"test": true
}
Discussion