Blog

Your WAF is decorative if the origin still answers

A WAF only inspects traffic that goes through it. If your load balancer's hostname accepts connections from anywhere, an attacker skips the WAF with one DNS lookup — and nothing in the dashboard will tell you.

Put Cloudflare in front of your application and you get a WAF, rate limiting, geo rules and DDoS absorption. The dashboard fills with green. Traffic is inspected.

Then somebody runs this:

curl https://my-alb-1457563835.us-east-1.elb.amazonaws.com/

If that returns your application, every control you just configured has been bypassed. Not defeated, not evaded — simply not involved. The request never went near Cloudflare.

This is the most common gap I see in this architecture, and it is invisible from the WAF’s own reporting, because a request that never arrives cannot appear in a log of blocked requests.

Two paths, one origin

Normal visitor Attacker with a scanner app.example.com CLOUDFLARE WAF rules · rate limiting · geo challenge DDoS absorption · TLS termination every request inspected here dig the ALB hostname no WAF, no rate limit, no geo rule, no logging AWS Application Load Balancer public DNS name, resolvable by anyone application, private subnet

The dashed path is the whole problem.

The WAF sits on one path to the origin. Unless the origin refuses every other path, it is a suggestion rather than a control.

An ALB’s DNS name is public and predictable in shape. It appears in certificate transparency logs, in old DNS records, in Shodan-style scan data, in a stale Terraform output someone pasted into a ticket. Finding it is not an exploit; it is a lookup.

Closing it

Two controls, and you want both.

1. Restrict the load balancer to the CDN’s addresses

Cloudflare publishes its egress ranges. The ALB security group should accept 443 from those ranges and nothing else:

resource "aws_security_group" "alb" {
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = var.cloudflare_ipv4_ranges   # NOT 0.0.0.0/0
    description = "HTTPS from Cloudflare edge only"
  }
}

Fetch the list rather than hardcoding it — Cloudflare does change it, and a stale entry shows up as “some visitors get a timeout” rather than as any obvious error. I generate the Terraform variable and the nginx set_real_ip_from list from one fetch of the published list so the two cannot drift apart.

2. Turn on Authenticated Origin Pulls

Address filtering is necessary but not sufficient: anyone else’s Cloudflare account also originates from those same ranges. Authenticated Origin Pulls makes Cloudflare present a client certificate that your origin verifies, so the origin accepts your zone rather than the edge in general.

Together: the network layer rejects everyone outside the CDN, and the TLS layer rejects everyone inside it who is not you.

Test it, don’t assume it

This is the part people skip. The configuration is easy; the proof is what tells you it worked.

curl -m 10 "https://$(terraform output -raw alb_dns_name)/healthz"; echo "exit=$?"

What you want:

curl: (28) Connection timed out after 10010 milliseconds
exit=28
Terminal showing three tests: a SQL injection probe returning 403, thirty login requests flipping from 200 to 429, and a direct curl to the ALB hostname timing out with exit code 28.
Three controls in one capture. The third is the one that makes the other two mean anything.

Exit 28 is a timeout, not a rejection, and the difference matters. A 403 or a TCP reset confirms something is listening. A timeout means packets are dropped with no reply at all — a scanner learns nothing, not even that the host exists. That is the security group discarding traffic rather than the application refusing it.

If instead you get a 200, or even a 502 from the load balancer, the origin is reachable and the WAF is optional.

What the WAF still cannot do

Worth being clear about, because closing the bypass does not make the WAF a solution.

On the free Cloudflare plan the Managed and OWASP rulesets are unavailable — they fail with “not entitled to execute this managed ruleset.” I wrote a substitute custom rule instead. On its first test it returned 200 for an obvious injection payload.

The reason: Cloudflare’s http.request.uri.query is the raw query string. My rule tested contains "' or '" against a value that was actually '%20or%20'. It could never have matched. Wrapping the field in url_decode() fixed it.

Which is the same lesson as the origin bypass, one layer up: a rule that is deployed, visible and green can still be doing nothing at all. The dashboard reports that a rule exists, not that it works. The only way to know is to send the payload and watch it get blocked.

And even working correctly, a substring rule stops the demo payload and naive scanners. It does not do grammar-aware detection, and an attacker who encodes or fragments the query walks straight past. Saying “Cloudflare WAF blocked SQL injection” on the strength of that would be overclaiming — the parameterised query in the application is what actually prevents injection. The WAF buys time against automation.

The checklist

  • Load balancer security group accepts the CDN’s ranges only, never 0.0.0.0/0
  • Ranges are fetched from the published list, not pasted once
  • Authenticated Origin Pulls enabled
  • curl against the origin hostname times out — verified, not assumed
  • Every WAF rule tested by sending the payload it is supposed to block
  • No other public entry point: no leftover A record, no debug port, no old environment still pointing at the same target group

That last one catches people. The origin does not have to be your load balancer. It can be a health-check endpoint on a NodePort, a staging environment sharing the backend, or a DNS record for a service you decommissioned but never deleted.

A WAF an attacker can walk around is decorative. Configuring it is the easy half; proving there is no other way in is the half that makes it a control.


From building a multi-AZ platform behind Cloudflare and then attacking it deliberately — the full write-up has the measured results and the defects found along the way.

References

← All posts