Implementing Secure Access Control using AWS WAF with IP Address and BASIC Authentication

snaka

Shinji NAKAMATSU

Posted on October 21, 2023

Implementing Secure Access Control using AWS WAF with IP Address and BASIC Authentication

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:

Overview Diagram

  • 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
  })
}


Enter fullscreen mode Exit fullscreen mode


// 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
}


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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"
  ]
  ... 
}


Enter fullscreen mode Exit fullscreen mode

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}")
}


Enter fullscreen mode Exit fullscreen mode

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"
                }
              }


Enter fullscreen mode Exit fullscreen mode

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\""
          }
        }
      }
    }


Enter fullscreen mode Exit fullscreen mode

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?
      }
    }
      }
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
snaka
Shinji NAKAMATSU

Posted on October 21, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related