🌎

CloudFrontのビューワーリクエストに含まれるヘッダーをCloudFront Functionsでレスポンスヘッダーに追加する

2024/08/03に公開

What

CloudFrontがビューワーからリクエストを受け取りオリジンに転送する際に、デバイスタイプや位置情報を特定するヘッダーを追加できる。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html

ユースケースによってはこの情報だけ欲しいというパターンもある。
オリジンはなんでもいいので、適当なコンテンツを配置し、ビューワーリクエストでCloudFrontが追加したヘッダーをレスポンスヘッダーに追加する処理をCloudFront Functionsで実装する。

ビューワーリクエストとビューワーレスポンスについては以下。

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html

今回はオリジンリクエストとオリジンレスポンスは操作しないので、Lambda@EdgeではなくCloudFront Functionsを用いるのがよい。
(CloudFront Functionsの方が機能的な制約は多いが、安くて制限が緩いので。)

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions-choosing.html

How

CloudFront Functionsのコードはこれ

function handler(event) {
  var response = event.response;
  var headers = response.headers;
  var request = event.request;

  var viewerInfoHeaders = [
    "cloudfront-viewer-city",
    "cloudfront-viewer-country",
    "cloudfront-viewer-country-name",
    'cloudfront-viewer-country-region',
    'cloudfront-viewer-country-region-name',
    'cloudfront-viewer-latitude',
    'cloudfront-viewer-longitude',
    'cloudfront-viewer-metro-code',
    'cloudfront-viewer-postal-code',
    'cloudfront-viewer-time-zone'
  ];

  viewerInfoHeaders.forEach(function(header) {
    if (request.headers[header]) {
      headers['x-' + header] = {value: request.headers[header].value};
    }
  });

  return response;
}

Terraformのサンプルコードを載せる。

resource "aws_cloudfront_origin_access_identity" "region" {
  comment = "origin access identity for region s3 bucket"
}

resource "aws_cloudfront_distribution" "region" {
  comment             = "cloudfront distribution for region bucket"
  enabled             = true
  aliases             = ["region.example.com"]
  default_root_object = "index.json"

  origin {
    domain_name = aws_s3_bucket.region.bucket_regional_domain_name
    origin_id   = "region"

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.region.cloudfront_access_identity_path
    }
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD", "OPTIONS"]
    target_origin_id       = "region"
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400

    forwarded_values {
      query_string = false
      headers = [
        "CloudFront-Viewer-Country",
        "CloudFront-Viewer-City",
        "CloudFront-Viewer-Country-Name",
        "CloudFront-Viewer-Country-Region",
        "CloudFront-Viewer-Country-Region-Name",
        "CloudFront-Viewer-Latitude",
        "CloudFront-Viewer-Longitude",
        "CloudFront-Viewer-Metro-Code",
        "CloudFront-Viewer-Postal-Code",
        "CloudFront-Viewer-Time-Zone"
      ]
      cookies {
        forward = "none"
      }
    }

    function_association {
      event_type   = "viewer-response"
      function_arn = aws_cloudfront_function.add_viewer_info_headers.arn
    }
  }

  price_class = "PriceClass_200"

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.example_com_us_east_1.arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }

  logging_config {
    bucket          = aws_s3_bucket.log.bucket_regional_domain_name
    include_cookies = false
    prefix          = "log/cloudfront/${aws_s3_bucket.region.bucket_regional_domain_name}"
  }
}

resource "aws_cloudfront_function" "add_viewer_info_headers" {
  name    = "add-viewer-info-headers"
  runtime = "cloudfront-js-1.0"
  comment = "ビューワーの地理情報をレスポンスヘッダーに追加"
  publish = true
  code    = <<-EOT
    function handler(event) {
      var response = event.response;
      var headers = response.headers;
      var request = event.request;

      var viewerInfoHeaders = [
        "cloudfront-viewer-city",
        "cloudfront-viewer-country",
        "cloudfront-viewer-country-name",
        'cloudfront-viewer-country-region',
        'cloudfront-viewer-country-region-name',
        'cloudfront-viewer-latitude',
        'cloudfront-viewer-longitude',
        'cloudfront-viewer-metro-code',
        'cloudfront-viewer-postal-code',
        'cloudfront-viewer-time-zone'
      ];

      viewerInfoHeaders.forEach(function(header) {
        if (request.headers[header]) {
          headers['x-' + header] = {value: request.headers[header].value};
        }
      });

      return response;
    }
  EOT
}

このCloudFrontを叩くと、こんな感じのレスポンスが返ってくる。

$ curl -I https://region.example.com
HTTP/2 200
content-type: application/json
~中略~
x-cloudfront-viewer-city: Tokyo
x-cloudfront-viewer-country: JP
x-cloudfront-viewer-country-name: Japan
x-cloudfront-viewer-country-region: 14
x-cloudfront-viewer-country-region-name: Tokyo
x-cloudfront-viewer-latitude: 12.12345
x-cloudfront-viewer-longitude: 12.12345
x-cloudfront-viewer-postal-code: 123-1234
x-cloudfront-viewer-time-zone: Asia/Tokyo

おまけ: S3の例

適当なコンテンツのTerraformのサンプルコードも載せておく。

resource "aws_s3_bucket" "region" {
  bucket = "region"
}

resource "aws_s3_bucket_policy" "region" {
  bucket = aws_s3_bucket.region.id
  policy = data.aws_iam_policy_document.region.json
}

data "aws_iam_policy_document" "region" {
  # NOTE: HTTPSのみ許可する
  statement {
    sid    = "AllowSSLRequestsOnly"
    effect = "Deny"
    principals {
      type        = "*"
      identifiers = ["*"]
    }
    actions = ["s3:*"]
    resources = [
      aws_s3_bucket.region.arn,
      "${aws_s3_bucket.region.arn}/*",
    ]
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }

  # NOTE: CFからのアクセスを許可する
  statement {
    sid     = "AllowCloudFrontAccess"
    effect  = "Allow"
    actions = ["s3:GetObject"]
    resources = [
      aws_s3_bucket.region.arn,
      "${aws_s3_bucket.region.arn}/*",
    ]
    principals {
      type        = "AWS"
      identifiers = [aws_cloudfront_origin_access_identity.region.iam_arn]
    }
  }
}

# NOTE: 意図しない公開を防ぐための設定
resource "aws_s3_bucket_public_access_block" "region" {
  bucket                  = aws_s3_bucket.region.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "region" {
  bucket = aws_s3_bucket.region.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_versioning" "region" {
  bucket = aws_s3_bucket.region.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "region" {
  bucket = aws_s3_bucket.region.id
  rule {
    id     = "expire-noncurrent-version"
    status = "Enabled"
    noncurrent_version_expiration {
      noncurrent_days = 7
    }
  }
}

# NOTE: ACLを無効にするための設定(バケットポリシーのみでアクセスコントロールする)
resource "aws_s3_bucket_ownership_controls" "region" {
  bucket = aws_s3_bucket.region.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_logging" "region" {
  bucket        = aws_s3_bucket.region.id
  target_bucket = aws_s3_bucket.log.id
  target_prefix = "log/s3/${aws_s3_bucket.region.bucket}"
}

resource "aws_s3_object" "region_root_object" {
  bucket       = aws_s3_bucket.region.bucket
  key          = "index.json"
  content_type = "application/json"
  content      = jsonencode({ status = "OK" })
}

Discussion