Deploying and Securing a Static Site, API, and Serverless Backend on AWS
— aws, terraform, security — 9 min read
WORK IN PROGRESS (This article is incomplete)
Introduction
The goal of this article is to lay out an approach to deploying secure static sites with serverless backends on AWS. We will declare and create our resources using Terraform.
The simplest way to set up a static site on AWS is to upload your files to S3 and enable static website hosting26. This approach, while straightforward, is limited, security wise, in that there aren't any built-in options aside from bucket policies to control who has access to your site.
We will, rather than utilize the native S3 static site hosting features, instead front our S3 bucket with CloudFront27, doing so confers a number of benefits such as:
- The ability to place your site behind a WAF
- Leverage CloudFront's global edge locations to serve your site and assets from a location near your requester resulting in potentially fewer S3 Get requests and faster response times
- Use a custom domain and serve your content over HTTPS
- Utilize Lambda@Edge or CloudFront Functions manipulate requests and responses
- Set up multiple origin configurations for failover and redundancy purposes
The next section contains more detail on the architecture and specific AWS resources we will be provisoning.
Infrastructure
The solution involves the following AWS resources:
- S3 (primary and failover origin)
- CloudFront (CDN)
- Lambda@Edge (associated with CloudFront distribution, invoked on viewer-request)
- API Gateway
- Lambda (custom authorizer associated with API Gateway)
- ACM (SSL certificates)
- Route53 ((DNS records))
- KMS (encryption for our logs and origin)
Outside of AWS, We will also make use of an identity provider service, in this case, Okta, but you may choose to substitute any OAuth2 compliant IdP.
Data Sources, Locals, and Providers
As with any Terraform project, I generally like to start by defining the providers1 I will be using, along with some data sources2 and local values3 which I can reference for convenience along the way while declaring the rest of the resources.
1terraform {2 required_providers {3 aws = {4 source = "hashicorp/aws"5 version = "~> 5.0"6 }7 }8 9 backend "s3" {}10}11
12provider "aws" {13 # This provider will be used to deploy most of our resources into us-east-114 region = "us-east-1"15}16
17provider "aws" {18 # This provider will be used to deploy our failover bucket into us-west-119 region = "us-west-1"20 alias = "us-west-1"21}
1data "aws_caller_identity" "current" {}2data "aws_region" "current" {}3
4data "aws_route53_zone" "public" {5 name = "${local.project_domain}."6 private_zone = false7}
locals { aws_account_id = data.aws_caller_identity.current.account_id aws_region = data.aws_region.current.name project_domain = "static.algoethica.com" public_hosted_zone_id = data.aws_route53_zone.public.zone_id
tags = { Service = "algoethica-static-site" }}
Encrypted Origin Module
Let's define a simple Terraform module for creating an encrypted S3 bucket intended to be used as an origin4 by CloudFront. The point of using a module here is to keep things neat and allow us to deploy primary and secondary (failover) buckets into different regions by passing a different AWS provider instance into each module declaration.
1terraform {2 required_providers {3 aws = {4 source = "hashicorp/aws"5 version = "~> 5.0"6 }7 }8}9
10data "aws_caller_identity" "current" {}11data "aws_region" "current" {}12
13variable "origin_name" {14 type = string15 description = "The name of the S3 origin bucket"16}17
18variable = "kms_key_arn" {19 type = string20 description = "The ARN of the KMS key to be used to encrypt the created S3 bucket. If no ARN is provided, a key will be created."21 default = ""22}23
24variable = "cloudfront_distribution_ids" {25 type = list(string)26 description = "The IDs of the CloudFront distributions which will be accessing this origin, used to restrict access to only this distribution via bucket policy"27 default = []28}29
30variable = "tags" {31 type = map(strings)32 description = "A map of tags to be applied to the created resources"33 default = {}34}35
36resource "aws_s3_bucket" "origin" {37 bucket = var.origin_name38 force_destroy = false39 object_lock_enabled = false40
41 tags = var.tags42}43
44resource "aws_kms_key" "origin" {45 count = var.kms_key_arn != "" ? 0 : 146 description = "This key is used to encrypt the ${var.origin_name} origins"47 key_usage = "ENCRYPT_DECRYPT"48 customer_master_key_spec = "SYMMTETRIC_DEFAULT"49 bypass_policy_lockout_safety_check = false50 deletion_window_in_days = 3051 is_enabled = true52 enable_key_rotation = true53 multi_region = true # Set to true since we will re-use the key to encrypt our failover origin in a second region54 tags = var.tags55 policy = data.aws_iam_policy_document.kms.json56}57
58resource "aws_s3_bucket_ownership_controls" "origin" {59 bucket = aws_s3_bucket.origin.id60
61 rule {62 object_ownership = "BucketOwnerEnforced"63 }64}65
66resource "aws_s3_bucket_public_access_block" "origin" {67 bucket = aws_s3_bucket.origin.id68
69 block_public_acls = true70 block_public_policy = true71 ignore_public_acls = true72 restrict_public_buckets = true73}74
75resource "aws_s3_bucket_server_side_encryption_configuration" "origin" {76 bucket = aws_s3_bucket.origin.id77
78 rule {79 apply_server_side_encryption_by_default {80 kms_master_key_id = var.kms_key_arn != "" ? var.kms_key_arn : aws_kms_key.origin[0].arn81 sse_algorithm = "aws:kms"82 }83 }84}85
86resource "aws_s3_bucket_policy" "origin" {87 bucket = aws_s3_bucket.origin.id88 policy = data.aws_iam_policy_document.origin.json89}90
91data "aws_iam_policy_document" "origin" {92 statement {93 sid = "AllowSSLRequestsOnly"94 actions = ["s3:*"]95 effect = "Deny"96
97 principals {98 type = "AWS"99 identifiers = ["*"]100 }101 102 resources = [103 "aws_s3_bucket.origin.arn",104 "${aws_s3_bucket.origin.arn}/*",105 ]106
107 condition {108 test = "Bool"109 variable = "aws:SecureTransport"110 values = ["false"]111 }112 }113 dynamic "statement" {114 for_each = var.cloudfront_distribution_ids115
116 sid = "AllowCloudFrontRead${each.value}"117 actions = ["s3:GetObject"]118 effect = "Allow"119
120 principals {121 type = "Service"122 identifiers = ["cloudfront.amazonaws.com"]123 }124
125 resources = [126 "${aws_s3_bucket.origin.arn}/*",127 ]128
129 condition {130 test = "StringEquals"131 variable = "AWS:SourceArn"132 values = ["arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${each.value}"]133 }134 }135}136
137data "aws_iam_policy_document" "kms" {138 statement {139 sid = "Enable IAM User Permissions"140 effect = "Allow"141 actions = ["kms:*"]142 resources = ["*"]143 144 principals = {145 AWS = "arn:aws:iam::${local.aws_account_id}:root"146 }147 }148 dynamic "statement" {149 for_each = var.cloudfront_distribution_ids150
151 sid = "AllowCloudFrontServicePrincipalSSE-KMS${each.value}"152 effect = "Allow"153 actions = [154 "kms:Decrypt",155 "kms:Encrypt",156 "kms:GenerateDataKey"157 ]158 resources = ["*"]159
160 principals {161 type = "Service"162 identifiers = ["cloudfront.amazonaws.com"]163 }164
165 condition {166 test = "StringEquals"167 variable = "AWS:SourceArn"168 values = ["arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${each.value}"]169 }170 }171}172
173output "s3_origin_id" {174 value = aws_s3_bucket.origin.id175}176
177output "s3_origin_arn" {178 value = aws_s3_bucket.origin.arn179}180
181output "kms_key_arn" {182 value = var.kms_key_arn != "" ? var.kms_key_arn : aws_kms_key.origin[0].arn183}
S3 and KMS
Now we can use the encrypted_s3_origin
module we just created to declare our primary and failover S3 origins. We will not pass in a KMS key in the first declaration, which will ensure a key is created, then we will pass the ARN of the created KMS key as input into the second declaration, so as to re-use the same key for both origins.
1module "primary_origin" {2 source = "./modules/s3_origin"3
4 origin_name = "algoethica-primary-origin"5 tags = local.tags6}7
8module "failover_origin" {9 source = "./modules/s3_origin"10 11 providers = {12 aws = aws.us-west-113 }14
15 origin_name = "algoethica-failover-origin"16 kms_key_arn = module.primary_origin.kms_key_arn17 tags = local.tags18}
WAF
We're going to declare a web ACL that will sit in front of and protect both our CloudFront distribution and API Gateway. WAF is a complex and capable service, we will aim to keep our configuration simple while achieving the following goals:
- Restrict access to only a handful of whitelisted IPs
- Block IP ranges associated with specified countries
- Utilize AWS baseline managed rules5 which attempt to block bot traffic or requests containing known malicious inputs.
1# TODO: Add IP whitelist logic2# TODO: Add other baseline rules3resource "aws_wafv2_web_acl" "basic" {4 name = "algoethica-static-site"5 description = "Example of a rule."6 scope = "REGIONAL"7
8 default_action {9 block {}10 }11
12 rule {13 name = "rule-1"14 priority = 115
16 override_action {17 none {}18 }19
20 statement {21 managed_rule_group_statement {22 name = "AWSManagedRulesCommonRuleSet"23 vendor_name = "AWS"24
25 scope_down_statement {26 geo_match_statement {27 country_codes = ["US"]28 }29 }30 }31 }32
33 visibility_config {34 cloudwatch_metrics_enabled = false35 metric_name = "friendly-rule-metric-name"36 sampled_requests_enabled = false37 }38 }39
40 tags = local.tags41
42 token_domains = [local.project_domain, "api.${local.project_domain}"]43
44 visibility_config {45 cloudwatch_metrics_enabled = false46 metric_name = "friendly-metric-name"47 sampled_requests_enabled = false48 }49}
ACM
We'll need an SSL certificate for our project domain. It will be used by both our CloudFront distribution and API Gateway custom domain. We should create an ACM certificate and also an accompanying Route53 record in the public hosted zone of our project domain for validation purposes.
1resource "aws_acm_certificate" "cert" {2 domain_name = local.project_domain3 subject_alternative_names = ["api.${local.project_domain}"]4 validation_method = "DNS"5
6 lifecycle {7 create_before_destroy = true8 }9}10
11resource "aws_route53_record" "cert" {12 for_each = {13 for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {14 name = dvo.resource_record_name15 record = dvo.resource_record_value16 type = dvo.resource_record_type17 }18 }19
20 allow_overwrite = true21 name = each.value.name22 records = [each.value.record]23 ttl = 6024 type = each.value.type25 zone_id = local.public_hosted_zone_id26}
CloudFront
After creating our S3 origins, it's time to declare the configuration for our CloudFront distribution. We will use OAC as opposed to OAI for accessing our S3 origins, as recommended by AWS6.
TODO: See if we need to disable overriding Authorization header
1resource "aws_cloudfront_origin_access_control" "example" {2 name = "example"3 description = "Example Policy"4 origin_access_control_origin_type = "s3"5 signing_behavior = "always"6 signing_protocol = "sigv4"7}8
9resource "aws_cloudfront_distribution" "s3_distribution" {10 origin {11 domain_name = aws_s3_bucket.b.bucket_regional_domain_name12 origin_access_control_id = aws_cloudfront_origin_access_control.default.id13 origin_id = local.s3_origin_id14 }15
16 enabled = true17 is_ipv6_enabled = true18 comment = "Some comment"s19 default_root_object = "index.html"20
21 logging_config {22 include_cookies = false23 bucket = "mylogs.s3.amazonaws.com"24 prefix = "myprefix"25 }26
27 aliases = [local.project_domain]28
29 default_cache_behavior {30 allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]31 cached_methods = ["GET", "HEAD"]32 target_origin_id = local.s3_origin_id33
34 forwarded_values {35 query_string = false36
37 cookies {38 forward = "none"39 }40 }41
42 viewer_protocol_policy = "allow-all"43 min_ttl = 044 default_ttl = 360045 max_ttl = 8640046 }47
48 # Cache behavior with precedence 049 ordered_cache_behavior {50 path_pattern = "/content/immutable/*"51 allowed_methods = ["GET", "HEAD", "OPTIONS"]52 cached_methods = ["GET", "HEAD", "OPTIONS"]53 target_origin_id = local.s3_origin_id54
55 forwarded_values {56 query_string = false57 headers = ["Origin"]58
59 cookies {60 forward = "none"61 }62 }63
64 min_ttl = 065 default_ttl = 8640066 max_ttl = 3153600067 compress = true68 viewer_protocol_policy = "redirect-to-https"69 }70
71 # Cache behavior with precedence 172 ordered_cache_behavior {73 path_pattern = "/content/*"74 allowed_methods = ["GET", "HEAD", "OPTIONS"]75 cached_methods = ["GET", "HEAD"]76 target_origin_id = local.s3_origin_id77
78 forwarded_values {79 query_string = false80
81 cookies {82 forward = "none"83 }84 }85
86 min_ttl = 087 default_ttl = 360088 max_ttl = 8640089 compress = true90 viewer_protocol_policy = "redirect-to-https"91 }92
93 price_class = "PriceClass_200"94
95 restrictions {96 geo_restriction {97 restriction_type = "whitelist"98 locations = ["US"]99 }100 }101
102 tags = local.tags103
104 viewer_certificate {105 acm_certificate_arn = aws_acm_certificate.cert.106 cloudfront_default_certificate = false107 minimum_protocol_version = "TLSv1.2_2021"108 ssl_support_method = "sni-only"109 }110}
Lambda
1# TODO: Lambda@Edge Okta Auth2
3# TODO: API Gateway Custom Authorizer
API Gateway
1# TODO: This
... TBD