Implementing Secure Access Control using AWS WAF with IP Address and BASIC Authentication
Tags: aws, security, terraform
Published: 2023-10-21 06:15:46

Photo by <a href="https://unsplash.com/@dayday95?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Damon Lam</a> on <a href="https://unsplash.com/photos/closed-blue-roller-shutter-kA2xo_jOhtE?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
  
---

## 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:

![Overview Diagram](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ix2i0o0o08hbg981adz0.png)


- 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:

```hcl
// modules/waf/variables.tf
// Module parameters
variable "allowed_ip_list" {}
variable "basic_auth" {
  type = object({
    user     = string
    password = string
  })
}
```

```terraform
// 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.

```hcl
// 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:

```hcl
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.

```hcl
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.

```hcl
              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.

```hcl
    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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate)



## Combining IP Address and BASIC Authentication

Combining the conditions mentioned earlier results in the following rule. (Details are omitted for clarity in structure)

```hcl
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

- [terraformを使ってAWS WAFだけでBasic認証を設定する](https://zenn.dev/trkdkjm/articles/41bb1f82a6e7b4)
- [Terraform base64encode function](https://developer.hashicorp.com/terraform/language/functions/base64encode)
- [WWW-Authenticate - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate)