Implementing Secure Access Control using AWS WAF with IP Address and BASIC Authentication
Shinji NAKAMATSU
Posted on October 21, 2023
Photo by Damon Lam on Unsplash
Introduction
AWS WAF is a Firewall service that can be attached to CloudFront or ALB. By defining a Web ACL (Access Control List), you can block or allow access based on specific conditions.
It is common to want to restrict who or where can access your service while developing a new service to be exposed on the internet.
In this article, we share how to combine AWS WAF for allowing access based on specific IP addresses (CIDR) and BASIC authentication.
Requirements
- Allow access if it matches the allowed IP addresses (CIDR)
- Allow access if the request header contains correct BASIC authentication information
- Block all other access
Overview of the Mechanism
Here's a summary of the mechanism:
- Default to "allow access"
- Assuming that when opening the service, just removing the rule would remove the access restriction
- Block access if the following (AND) conditions are met:
- Not an allowed IP address
- Lacks BASIC authentication information
- When blocking access, request BASIC authentication with a custom response (
WWW-Authenticate
header is returned)
Terraform Code
Here's the code snippet:
// modules/waf/variables.tf
// Module parameters
variable "allowed_ip_list" {}
variable "basic_auth" {
type = object({
user = string
password = string
})
}
// modules/waf/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.virginia]
}
}
}
locals {
// Pre-calculate the expected value for the Authorization header
credential = base64encode("${var.basic_auth.user}:${var.basic_auth.password}")
}
resource "aws_wafv2_web_acl" "main" {
provider = aws.virginia
name = "example-web-acl"
description = "Web ACL example"
scope = "CLOUDFRONT"
// Set the default action to allow
default_action {
allow {}
}
// BASIC Authentication
rule {
name = "basic-auth"
priority = 20
action {
block {
// Set the action to block for the conditions mentioned later
// Also, return a custom response requesting username and
// password input for BASIC authentication
custom_response {
response_code = 401
response_header {
name = "WWW-Authenticate"
value = "Basic realm=\"Secure Area\""
}
}
}
}
// Conditions for Block (AND) :
statement {
and_statement {
statement {
// Condition 1
// The source IP address is not included in the allowed IP list
not_statement {
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.allowed_ips.arn
}
}
}
}
statement {
// Condition 2
// The Authorization header does not contain
not_statement {
statement {
byte_match_statement {
positional_constraint = "EXACTLY"
search_string = "Basic ${local.credential}"
field_to_match {
single_header {
name = "authorization"
}
}
text_transformation {
priority = 0
type = "NONE"
}
}
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "example-basic-auth"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "example-web-acl"
sampled_requests_enabled = true
}
}
// Set of allowed IP addresses for access
resource "aws_wafv2_ip_set" "allowed_ips" {
provider = aws.virginia
name = "example-allowed-ips"
description = "Authorized IP addresses"
scope = "CLOUDFRONT"
ip_address_version = "IPV4"
addresses = var.allowed_ip_list
}
IP Address-based Access Control Settings
To perform access control based on IP addresses in AWS WAF, you first need to prepare an IP set.
// Set of allowed IP addresses
resource "aws_wafv2_ip_set" "allowed_ips" {
provider = aws.virginia
name = "example-allowed-ips"
description = "Authorized IP addresses"
scope = "CLOUDFRONT"
ip_address_version = "IPV4"
addresses = var.allowed_ip_list
}
In the above, the list of IP addresses is stored in var.allowed_ip_list
, and it's assumed that the module user will pass it as a parameter in the following manner:
module "waf" {
src = "../modules/waf"
allowed_ip_list = [
"xxx.xxx.xxx.xxx/32",
"yyy.yyy.yyy.yyy/32",
"zzz.zzz.zzz.zzz/32"
]
...
}
BASIC Authentication Settings
In the code below, we pre-calculate the string expected to be stored in the BASIC authentication header.
locals {
// Pre-calculate the expected value for the Authorization header
credential = base64encode("${var.basic_auth.user}:${var.basic_auth.password}")
}
In the following block, we reference that value to check whether the corresponding string is stored in the request's Authorization header.
byte_match_statement {
positional_constraint = "EXACTLY"
search_string = "Basic ${local.credential}"
field_to_match {
single_header {
name = "authorization"
}
}
text_transformation {
priority = 0
type = "NONE"
}
}
If the conditions do not match, the block below is set to return a custom response.
action {
block {
// Set the action to block for the conditions mentioned later
// Also, return a custom response requesting username and
// password input for BASIC authentication
custom_response {
response_code = 401
response_header {
name = "WWW-Authenticate"
value = "Basic realm=\"Secure Area\""
}
}
}
}
In the custom response, the WWW-Authenticate
response header requests BASIC authentication from the source. Typically, when a browser receives this response, it displays a dialog prompting for BASIC authentication information (User/Password).
Reference: WWW-Authenticate - HTTP | MDN
Combining IP Address and BASIC Authentication
Combining the conditions mentioned earlier results in the following rule. (Details are omitted for clarity in structure)
rule {
// If the following statement matches, block the access and return a custom response
action {
block {
// Request BASIC authentication in the custom response
}
}
statement {
and_statement {
// The source IP address is not in the allowed IP list
statement {
// Invert the condition here
not_statement {
statement {
// Does the IP address match the allowed IP list?
}
}
}
// BASIC authentication information is not included in the Authorization header
statement {
// Invert the condition here
not_statement {
statement {
// Is BASIC authentication information included in the Authorization header?
}
}
}
}
}
}
Note that, as the negation (not_statement
) cannot be described directly under the rule
block or and_statement
, it needs to be re-wrapped with the statement
block, making the structure somewhat difficult to understand.
In summary, this results in the code mentioned earlier.
Conclusion
We introduced a method to allow access to specific users using AWS WAF by combining IP address-based strict access control and looser access control through BASIC authentication, which can flexibly accommodate cases where temporary access is needed.
One point to note is that if you apply this to APIs that require authentication methods other than BASIC authentication (e.g., OAuth), some modification on the client-side using the API may be needed, bringing infrastructural concerns into the application layer. It's also viable to exclude such APIs from this rule if they already have authentication in place, depending on the risk assessment.
I hope this article helps someone out there.
References
Posted on October 21, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.