Application Load Balancer - Drop Invalid Headers
24 April 2025
We use HTTP headers all the time. In requests we do, we often send cookies, authorization headers with bearer tokens, content length, we get responses with new cookies, expiration dates for caching. We don't think too much about this, even when building things like a REST application. However, it is a nice point for malicious actors to try exploiting our vulnerabilities. That's why today I want to demonstrate you how "drop invalid headers" feature was implemented in AWS Elastic Load Balancer.
Code for this post available on GitHub
The mysterious setting
When creating an ALB in AWS, not many of us think about this setting. We mostly focus on health checks, SSL certificates, security groups, draining timeout. And this setting just sits there untouched. According to Aqua Sec, lack of this setting is makred as high risk AVD-AWS-0052.
However, it is very easy to enable. What we need to keep in mind is that our application might not like it if we didn't conform to standards. To enable this feature in Terraform we need to just add this third line:
resource "aws_alb" "ALB" {
name = "my-load-balancer"
security_groups = [data.aws_security_group.alb_sg.id]
drop_invalid_header_fields = true
...
}
Let's build a simple ALB that will just return a fixed response. In order to do this I prepare a Terraform that I will deploy with OpenTofu. As this is just a test, there's no need for SSL certificates or HTTPS listener - the secure one will work the same way as plaintext listener.
In order to create an Elastic Load Balancer, we need a VPC in our AWS
environment (including public subnets, Internet Gateway, route tables, etc.) and
a security group that will allow us to connect to the ELB. For simplicity, I
will use here aws-ia/vpc
module
that will allow us to construct almost all the components with minimal work
required (and make it cleaner than any LLM output).
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" { region = "eu-west-2" }
module "vpc" {
source = "aws-ia/vpc/aws"
version = ">= 4.2.0"
name = "alb-example"
cidr_block = var.vpc_cidr
az_count = 2
subnets = {
public = { netmask = 24 }
}
}
Now in a new file we can define our load balancer with a security group, listener and a fixed response. Useful for us will also be the output with the URL we can visit to see the response.
resource "aws_security_group" "alb_security_group" {
name = "alb-security-group"
vpc_id = module.vpc.vpc_attributes.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_alb" "sample" {
name = "sample-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb_security_group.id]
subnets = values(module.vpc.public_subnet_attributes_by_az)[*].id
}
resource "aws_alb_listener" "listener" {
load_balancer_arn = aws_alb.sample.arn
port = "80"
protocol = "HTTP"
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/html"
message_body = "<html><body><h3>Fixed response!</h3></body></html>"
status_code = "200"
}
}
}
output "alb_dns_name" {
value = aws_alb.sample.dns_name
}
For now the load balancer should just reply to us with some HTML. We can test it
in the browser as well as using curl
in our Terminal. The latter will be later
useful to provide some invalid headers. Let's see if the ALB is at least
functional.
# Or simply copy and paste the URL after http://
$ curl http://$(tofu output -raw alb_dns_name)
<html><body><h3>Fixed response!</h3></body></html>
Good to see that we have a response. We can turn on verbose mode to see everything that is happening under the hood, all the headers going around and HTTP status code.
$ curl -v http://$(tofu output -raw alb_dns_name)
* Host sample-alb-1234567.eu-west-2.elb.amazonaws.com:80 was resolved.
* IPv6: (none)
* IPv4: 3.11.255.255
* Trying 3.11.255.255:80...
* Connected to sample-alb-1234567.eu-west-2.elb.amazonaws.com (3.11.255.255) port 80
> GET / HTTP/1.1
> Host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Sat, 19 Apr 2025 07:13:47 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 50
< Connection: keep-alive
<
* Connection #0 to host sample-alb-1234567.eu-west-2.elb.amazonaws.com left intact
<html><body><h3>Fixed response!</h3></body></html>
Trying invalid headers
I will try now sending some headers. One of them should be invalid. I will not turn on the setting so that all the headers should be accepted.
$ curl -v \
-H 'X-Custom-Header: valid-header' \
-H 'X-!NotValid@Header😭: very&badlyformatted:query' \
http://$(tofu output -raw alb_dns_name)
* ...
> GET / HTTP/1.1
> Host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
> User-Agent: curl/8.7.1
> Accept: */*
> X-Custom-Header: valid-header
> X-!NotValid@Header😭: very&badlyformatted:query
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Sat, 19 Apr 2025 08:13:46 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 50
< Connection: keep-alive
<
* Connection #0 to host sample-alb-1234567.eu-west-2.elb.amazonaws.com left intact
<html><body><h3>Fixed response!</h3></body></html>
Everything went through, no errors reported. Let's see what will happen if we turn on "Drop invalid headers". Should we see an error or is it just a filter?
resource "aws_alb" "sample" {
name = "sample-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb_security_group.id]
subnets = values(module.vpc.public_subnet_attributes_by_az)[*].id
drop_invalid_header_fields = true
}
$ tofu apply
...
$ curl -v \
-H 'X-Custom-Header: valid-header' \
-H 'X-!NotValid@Header😭: very&badlyformatted:query' \
http://$(tofu output -raw alb_dns_name)
...
* Connected to sample-alb-1234567.eu-west-2.elb.amazonaws.com (3.11.255.255) port 80
> GET / HTTP/1.1
> Host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
> User-Agent: curl/8.7.1
> Accept: */*
> X-Custom-Header: valid-header
> X-!NotValid@Header😭: very&badlyformatted:query
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Sat, 19 Apr 2025 08:17:30 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 50
< Connection: keep-alive
<
* Connection #0 to host sample-alb-1234567.eu-west-2.elb.amazonaws.com left intact
<html><body><h3>Fixed response!</h3></body></html>
Exploring access logs
Still, we don't get any error. Let's do another thing - I suggest enabling ELB logs to see if they actually say anything about dropped headers. For this I need an S3 bucket where I will store the logs and a policy that will enable ELB to put new objects. I will then direct our load balancer to do this. You should look up your region here: Attach Bucket Policy
locals {
elb_account_id = "652711504416" # ELB account of eu-west-2
}
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "logs_bucket" {
bucket = "logs-bucket-5535325"
}
# This policy should restrict the path to which ELB can write because if we just
# put '*' in Resource, it will allow anyone to use our bucket for their ALB
# logs.
resource "aws_s3_bucket_policy" "log_delivery_policy" {
bucket = aws_s3_bucket.logs_bucket.bucket
policy = <<-EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::${local.elb_account_id}:root",
"Service": "logdelivery.elasticloadbalancing.amazonaws.com"
},
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::${aws_s3_bucket.logs_bucket.bucket}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
}
]
}
EOF
}
resource "aws_alb" "sample" {
name = "sample-alb"
...
access_logs {
bucket = aws_s3_bucket.logs_bucket.bucket
enabled = true
}
}
# Adapt bucket name, account number, region and date
$ mkdir -p logs
$ aws s3 cp --recursive \
s3://logs-bucket-5535325/AWSLogs/999900000011/elasticloadbalancing/eu-west-2/2025/04/19/ \
./logs/
$ cd logs
$ for f in *.gz; do gunzip $f; done
I have collected some samples with both requests with proper headers and invalid
ones. You can't see the actual headers in the logs but based on the size
difference we can find which ones contained additional headers - the larger ones
with 193
bytes input definitely had extra headers whereas the smaller ones of
111
bytes in "received_bytes" field did only contain default ones. (For
reference the fields in the logs are the following:
Access Logs Syntax).
http 2025-04-19T08:42:56.351174Z app/sample-alb/c1aabbcc22334455 89.255.255.255:50804 - -1 -1 -1 200 - 193 190 "GET http://sample-alb-12345678.eu-west-2.elb.amazonaws.com:80/ HTTP/1.1" "curl/8.7.1" - - - "Root=1-68036210-464146a9138538586ff5e14e" "-" "-" 0 2025-04-19T08:42:56.351000Z "fixed-response" "-" "-" "-" "-" "-" "-" TID_9b351616fe757a4eb05b32904a9d92e2
http 2025-04-19T08:43:01.041946Z app/sample-alb/c1aabbcc22334455 89.255.255.255:50806 - -1 -1 -1 200 - 111 190 "GET http://sample-alb-12345678.eu-west-2.elb.amazonaws.com:80/ HTTP/1.1" "curl/8.7.1" - - - "Root=1-68036215-0636826c46a6680554d0583d" "-" "-" 0 2025-04-19T08:43:01.041000Z "fixed-response" "-" "-" "-" "-" "-" "-" TID_29c56d96bfdd254f9feda0b2495f58d5
http 2025-04-19T08:43:02.840550Z app/sample-alb/c1aabbcc22334455 89.255.255.255:50807 - -1 -1 -1 200 - 193 190 "GET http://sample-alb-12345678.eu-west-2.elb.amazonaws.com:80/ HTTP/1.1" "curl/8.7.1" - - - "Root=1-68036216-478dc44c3c867e922e33854f" "-" "-" 0 2025-04-19T08:43:02.840000Z "fixed-response" "-" "-" "-" "-" "-" "-" TID_5bec2e45efdb2d47a359a47534333ef1
http 2025-04-19T08:42:59.706809Z app/sample-alb/c1aabbcc22334455 89.255.255.255:50805 - -1 -1 -1 200 - 111 190 "GET http://sample-alb-12345678.eu-west-2.elb.amazonaws.com:80/ HTTP/1.1" "curl/8.7.1" - - - "Root=1-68036213-54037c2e02539c33533406e4" "-" "-" 0 2025-04-19T08:42:59.706000Z "fixed-response" "-" "-" "-" "-" "-" "-" TID_eadfeace8e8bd444a93cf651d68004e7
As you see there's no indication of bad headers whatsoever in the access logs. But important to note is that these feature is not designed to secure Elastic Load Balancer itself, rather to make sure that no exploits in this field are passed to the upstream services.
Viewing the headers sent
Because of that, I will construct a Lambda function that will just be the default target of our load balancer. It will simply respond with a page and print out all the headers directly. Let's build it with Python.
def lambda_handler(event, context):
headers = event.get('headers', {})
headers = [
f"* {header_name}: {header_value}"
for header_name, header_value in headers.items()
]
body = "\n".join(headers)
return {
'statusCode': 200,
'statusDescription': '200 OK',
'isBase64Encoded': False,
'headers': {
'Content-Type': 'text/plain',
},
'body': body
}
I will also use Terraform with archive
provider to pack and upload the Lambda.
There are also some IAM permissions that are required. First, each Lambda needs
an IAM role. I will grant "AWSLambdaBasicExecutionRole" allowing to send logs to
CloudWatch which will help us debug if necessary. What is more we need to let
Elastic Loadbalancing to execute the function.
resource "aws_iam_role" "lambda_role" {
name = "lambda_alb_role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [ {
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
} ]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "archive_file" "lambda_zip" {
type = "zip"
source_content = file("${path.module}/lambda.py")
source_content_filename = "lambda.py"
output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "headers_lambda" {
filename = data.archive_file.lambda_zip.output_path
function_name = "display-request-headers"
role = aws_iam_role.lambda_role.arn
handler = "lambda.lambda_handler"
runtime = "python3.12"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
timeout = 15
memory_size = 128
}
# Permission for ALB to invoke Lambda
resource "aws_lambda_permission" "alb_invoke" {
statement_id = "AllowALBInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.headers_lambda.function_name
principal = "elasticloadbalancing.amazonaws.com"
}
# Target group with Lambda
resource "aws_lb_target_group" "lambda_tg" {
name = "lambda-headers-tg"
target_type = "lambda"
}
resource "aws_lb_target_group_attachment" "lambda_tg_attachment" {
target_group_arn = aws_lb_target_group.lambda_tg.arn
target_id = aws_lambda_function.headers_lambda.arn
depends_on = [aws_lambda_permission.alb_invoke]
}
In order to test that all the headers are passed, I will need to first disable
the drop_invalid_header_fields
option we activated previously in the ALB. Only
then our experiment will make any sense. Also I will swtich the default behavior
of our listener to forward all the requests to Lambda target group instead of
a fixed response.
resource "aws_alb" "sample" {
name = "sample-alb"
drop_invalid_header_fields = false
...
}
resource "aws_alb_listener" "listener" {
load_balancer_arn = aws_alb.sample.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.lambda_tg.arn
}
}
...
Viewing the headers
After we have deployed, we can perform some cURL requests to the load balancer.
This is just a plain GET
request with no additional headers. It just prints
all the headers as we expect it to.
$ curl http://$(tofu output -raw alb_dns_name)
* accept: */*
* host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
* user-agent: curl/8.7.1
* x-amzn-trace-id: Root=1-6809c847-59e13c41052857933ee95d5d
* x-forwarded-for: 5.5.1.1
* x-forwarded-port: 80
* x-forwarded-proto: http
So now what we can do is pass a valid header and then try passing invalid ones. I will perform some cURL requests again with the headers and we will see what is the result.
$ curl -H "Test-Header: valid" http://$(tofu output -raw alb_dns_name)
* accept: */*
* host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
* test-header: valid
* user-agent: curl/8.7.1
...
$ curl \
-H "Test-Header: valid" \
-H "Test@\$\!Header:: : \!\$Invalid*" \
-H "Emoji👌Header: 😂should-be-bad" \
http://$(tofu output -raw alb_dns_name)
* accept: */*
* emoji👌header: 😂should-be-bad
* host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
* test-header: valid
* test@$!header: : : !$Invalid*
* user-agent: curl/8.7.1
...
As we can see, all the headers are passed unmodified - all the characters are received by Lambda. In most cases this is not a problem but everything depends on how we implement the application logic. In order to reduce the risk area, let's again drop the invalid ones.
resource "aws_alb" "sample" {
name = "sample-alb"
drop_invalid_header_fields = true
...
}
This time the invalid headers are nowhere to be seen. Only the clean ones are
passed forward to the Lambda function. The drop_invalid_header_fields
option
applies only to the header names. In the second example below, you can see that
passing any characters after the colon is supported. Header names can contain
only alphanumeric characters and dashes (not even underscores or dots!). If we
add more colons, only the first one counts, others are treated as part of the
value.
$ curl \
-H "Test-Header: valid" \
-H "Test@\$\!Header:: : \!\$Invalid*" \
-H "Emoji👌Header: 😂should-be-bad" \
http://$(tofu output -raw alb_dns_name)
* accept: */*
* host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
* test-header: valid
* user-agent: curl/8.7.1
* x-amzn-trace-id: Root=1-6809cac3-71532e567ea76f3645b6fb43
* x-forwarded-for: 5.5.1.1
* x-forwarded-port: 80
* x-forwarded-proto: http
$ curl \
-H "Test-Header: valid" \
-H "Good-Header:: : \!\$Invalid*" \
-H "Emoji-Header: 😂should-be-good-now😁" \
http://$(tofu output -raw alb_dns_name)
* accept: */*
* emoji-header: 😂should-be-good-now😁
* good-header: : : !$Invalid*
* host: sample-alb-1234567.eu-west-2.elb.amazonaws.com
* test-header: valid
* user-agent: curl/8.7.1
* x-amzn-trace-id: Root=1-6809cb50-324909f00da55f240e1382ef
* x-forwarded-for: 5.5.1.1
* x-forwarded-port: 80
* x-forwarded-proto: http