Skip to content
algoethica
TwitterHomepage

Deploying and Securing a Static Site, API, and Serverless Backend on AWS

aws, terraform, security9 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.

infrastructure/versions.tf
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-1
14 region = "us-east-1"
15}
16
17provider "aws" {
18 # This provider will be used to deploy our failover bucket into us-west-1
19 region = "us-west-1"
20 alias = "us-west-1"
21}
infrastructure/data.tf
1data "aws_caller_identity" "current" {}
2data "aws_region" "current" {}
3
4data "aws_route53_zone" "public" {
5 name = "${local.project_domain}."
6 private_zone = false
7}
infrastructure/locals.tf
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.

infrastructure/modules/encrypted_s3_origin/main.tf
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 = string
15 description = "The name of the S3 origin bucket"
16}
17
18variable = "kms_key_arn" {
19 type = string
20 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_name
38 force_destroy = false
39 object_lock_enabled = false
40
41 tags = var.tags
42}
43
44resource "aws_kms_key" "origin" {
45 count = var.kms_key_arn != "" ? 0 : 1
46 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 = false
50 deletion_window_in_days = 30
51 is_enabled = true
52 enable_key_rotation = true
53 multi_region = true # Set to true since we will re-use the key to encrypt our failover origin in a second region
54 tags = var.tags
55 policy = data.aws_iam_policy_document.kms.json
56}
57
58resource "aws_s3_bucket_ownership_controls" "origin" {
59 bucket = aws_s3_bucket.origin.id
60
61 rule {
62 object_ownership = "BucketOwnerEnforced"
63 }
64}
65
66resource "aws_s3_bucket_public_access_block" "origin" {
67 bucket = aws_s3_bucket.origin.id
68
69 block_public_acls = true
70 block_public_policy = true
71 ignore_public_acls = true
72 restrict_public_buckets = true
73}
74
75resource "aws_s3_bucket_server_side_encryption_configuration" "origin" {
76 bucket = aws_s3_bucket.origin.id
77
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].arn
81 sse_algorithm = "aws:kms"
82 }
83 }
84}
85
86resource "aws_s3_bucket_policy" "origin" {
87 bucket = aws_s3_bucket.origin.id
88 policy = data.aws_iam_policy_document.origin.json
89}
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_ids
115
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_ids
150
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.id
175}
176
177output "s3_origin_arn" {
178 value = aws_s3_bucket.origin.arn
179}
180
181output "kms_key_arn" {
182 value = var.kms_key_arn != "" ? var.kms_key_arn : aws_kms_key.origin[0].arn
183}

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.

infrastructure/s3.tf
1module "primary_origin" {
2 source = "./modules/s3_origin"
3
4 origin_name = "algoethica-primary-origin"
5 tags = local.tags
6}
7
8module "failover_origin" {
9 source = "./modules/s3_origin"
10
11 providers = {
12 aws = aws.us-west-1
13 }
14
15 origin_name = "algoethica-failover-origin"
16 kms_key_arn = module.primary_origin.kms_key_arn
17 tags = local.tags
18}

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.
infrastructure/waf.tf
1# TODO: Add IP whitelist logic
2# TODO: Add other baseline rules
3resource "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 = 1
15
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 = false
35 metric_name = "friendly-rule-metric-name"
36 sampled_requests_enabled = false
37 }
38 }
39
40 tags = local.tags
41
42 token_domains = [local.project_domain, "api.${local.project_domain}"]
43
44 visibility_config {
45 cloudwatch_metrics_enabled = false
46 metric_name = "friendly-metric-name"
47 sampled_requests_enabled = false
48 }
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.

infrastructure/acm.tf
1resource "aws_acm_certificate" "cert" {
2 domain_name = local.project_domain
3 subject_alternative_names = ["api.${local.project_domain}"]
4 validation_method = "DNS"
5
6 lifecycle {
7 create_before_destroy = true
8 }
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_name
15 record = dvo.resource_record_value
16 type = dvo.resource_record_type
17 }
18 }
19
20 allow_overwrite = true
21 name = each.value.name
22 records = [each.value.record]
23 ttl = 60
24 type = each.value.type
25 zone_id = local.public_hosted_zone_id
26}

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

infrastructure/cloudfront.tf
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_name
12 origin_access_control_id = aws_cloudfront_origin_access_control.default.id
13 origin_id = local.s3_origin_id
14 }
15
16 enabled = true
17 is_ipv6_enabled = true
18 comment = "Some comment"s
19 default_root_object = "index.html"
20
21 logging_config {
22 include_cookies = false
23 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_id
33
34 forwarded_values {
35 query_string = false
36
37 cookies {
38 forward = "none"
39 }
40 }
41
42 viewer_protocol_policy = "allow-all"
43 min_ttl = 0
44 default_ttl = 3600
45 max_ttl = 86400
46 }
47
48 # Cache behavior with precedence 0
49 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_id
54
55 forwarded_values {
56 query_string = false
57 headers = ["Origin"]
58
59 cookies {
60 forward = "none"
61 }
62 }
63
64 min_ttl = 0
65 default_ttl = 86400
66 max_ttl = 31536000
67 compress = true
68 viewer_protocol_policy = "redirect-to-https"
69 }
70
71 # Cache behavior with precedence 1
72 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_id
77
78 forwarded_values {
79 query_string = false
80
81 cookies {
82 forward = "none"
83 }
84 }
85
86 min_ttl = 0
87 default_ttl = 3600
88 max_ttl = 86400
89 compress = true
90 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.tags
103
104 viewer_certificate {
105 acm_certificate_arn = aws_acm_certificate.cert.
106 cloudfront_default_certificate = false
107 minimum_protocol_version = "TLSv1.2_2021"
108 ssl_support_method = "sni-only"
109 }
110}

Lambda

infrastructure/lambda.tf
1# TODO: Lambda@Edge Okta Auth
2
3# TODO: API Gateway Custom Authorizer

API Gateway

infrastructure/api_gateway.tf
1# TODO: This

... TBD

References