AWS CloudFront is an extremely powerful service, which gives you a global Content Delivery Network (CDN) with over 100 points of presence, as well as robust DDOS protection and mitigation, edge caching, TLS termination, HTTP to HTTPS redirection, content streaming, and routing rules. Even with caching turned off, this is a service that you want to be fronting your website.
While it has a few flaws, like the time it takes to roll out a change, overall it's a solid, reliable and essential part of hosting a modern website. It can also be extended with Lambda@Edge, which allows you to run a lambda function on every request, which can modify parts of the result before sending it to the user.
One thing that CloudFront is missing, that a lot of people need, is IP whitelisting. This is useful if you want to lock your site down to a specific set of IP addresses - eg before a site launches - or in reverse, and more commonly, block a range of IPs from accessing your site.
Normally you could use a security group to handle the whitelisting - don’t allow from 0.0.0.0/0
, just put in the list of CIDRs you want to allow and you’re done. CloudFront, however, doesn’t support security groups, so this option isn’t available.
Enter the AWS Web Application Firewall - WAF.
A WAF is normally used to inspect traffic for attacks like SQL Injection or Cross Site Scripting (XSS), and block them. Like Marmite, WAFs are universally loved (by PCI) or hated (by pretty much every developer and administrator who's had one forced on them). They are, however, very useful when done well.
As well as SQLi and XSS, the AWS WAF also has
- IP matching - a rule which matches if the source address is on a list of addresses
- String and byte matching in various fields, using lists, partial matches and regular expressions
- Custom WAF rules from 3rd party providers like Fortinet or AlertLogic
- AWS Shield, which provides DDOS mitigation (not proactive - that’s Shield Advanced, which is rather pricey, unless you need it, then it’s exceptionally cheap)
The WAF consists of a number of pieces
- The WAF ACL (Access Control List): a data structure which defines which rules your WAF contains, how it reports metrics and other configuration information. This is the root of the WAF structure and the entity that you attach to things which want to use the WAF.
- A WAF ACL has one or more WAF Rules attached to it. If a rule matches, its action is applied (block, allow, count) and processing stops. Multiple rules in an ACL are applied in an OR manner. If no rules match, the default action applies.
- A rule is made up of a list of predicates which are applied in an AND manner - for the rule to fire, you have to have (eg) an ip in the IP list predicate and also a specific string in a header field (string match predicate).
Predicates can also be negated (match if it’s not on the list). Each predicate has a matching type - Byte, Geographic, IP, Regex, Size constraint, SQL Injection and Cross Site Scripting. - Each WAF Rule has one or more WAF Sets attached, eg a IPSet, StringSet, GeoMatchSet. This is a list of items which make up the predicate, and could be a list of IP addresses, a set of strings and fields to match, where to look for XSS and SQLi, areas of the world for a geographic match, or other configuration.
- AWS WAF also supports Managed Rules, which can be bought in the AWS Marketplace. These do not have sets, and you can just include them, rather than providing any configuration.
So a WAF ACL looks something like:
- If the IP is in the list, ALLOW (Rule, priority 1)
- If the string is not in the list, BLOCK (Rule, priority 2)
- If nothing above matched, COUNT (default action)
If the user is blocked, they will receive a 403 error from CloudFront, which you can customize. This is different to a security group rule on an ALB, which will just ignore traffic that doesn't match.
Using CloudFront and WAF to pinhole a service
You can use CloudFront and WAF to easily pinhole a service to your IP address. You need to setup the following in Terraform
locals {
# the name of the ACL, can have _ etc in it
acl_id = "foo"
# the name of the CloudWatch metric - must be a-z only
metric_name = "foo"
# the list of IPs we want to whitelist
cidr_whitelist = [
"202.14.100.0/24",
"8.8.8.8/32",
"1.1.1.1/32",
]
}
resource "aws_waf_web_acl" "waf_acl" {
name = "${local.acl_id}_waf_acl"
metric_name = "${local.metric_name}wafacl"
default_action {
type = "BLOCK"
}
rules {
priority = 10
rule_id = aws_waf_rule.ip_whitelist.id
action {
type = "ALLOW"
}
}
depends_on = [
"aws_waf_rule.ip_whitelist",
"aws_waf_ipset.ip_whitelist"
]
}
resource "aws_waf_rule" "ip_whitelist" {
name = "${local. acl_id}_ip_whitelist_rule"
metric_name = "${local.metric_name}ipwhitelist"
depends_on = ["aws_waf_ipset.ip_whitelist"]
predicates {
data_id = aws_waf_ipset.ip_whitelist.id
negated = false
type = "IPMatch"
}
}
resource "aws_waf_ipset" "ip_whitelist" {
name = "${local. acl_id}_match_ip_whitelist"
# dynamic below generates this from the list
#
# ip_set_descriptors {
# type = "IPV4"
# value = "8.8.8.8/32"
# }
dynamic "ip_set_descriptors" {
for_each = toset(local.cidr_whitelist)
content {
type = "IPV4"
value = ip_set_descriptors.key
}
}
}
Once you have that, you can then connect it to the CloudFront distribution
resource "aws_cloudfront_distribution" "cloudfront" {
...
web_acl_id = aws_waf_web_acl.waf_acl.id
...
}
If you have more than one CloudFront distribution you want to use for this, you can attach the same ACL to multiple distributions, which does drop the cost a bit ($1/month). However, the management can be easier if it's one ACL per distribution, depending on how you structure your Terraform code.
You can also do this to an ALB or API Gateway, but you need to use the aws_regional_waf_acl
structure (and other regional
resources). The concept is identical.
While the black box nature of the SQLi and XSS rules can be frustrating at times, the other features of the AWS WAF are very useful, and can help you secure your site from unwanted attention.