This one started with a scanner.

I run a small TLS edge on a VPS — HAProxy in front of a media relay, nothing exotic. And like anyone who has ever watched a raw access log for ten minutes, I wanted the bare-IP requests gone. The server answered on its IP address just as happily as on its real hostname, which meant every bot on the internet that scans by address got a full page load for free. Hostname gate: if the Host header isn't mine, deny. Textbook.

So I wrote the textbook version:

frontend fe_443
    bind *:443 ssl crt /etc/haproxy/certs/site.pem
    acl my_host hdr(Host) -i my.host.example
    http-request deny deny_status 403 if !my_host
    default_backend origin

I ran the config check, it said Configuration file is valid, I reloaded, I tested the hostname, got a 200, and moved on with my life.

That was mistake number one: I tested the allow path and called it verified.

The symptom that wouldn't go away

A few weeks later I noticed the server was still happily answering bare-IP requests. Same 200. Same content. The ACL I had written — validated, deployed, tested — was apparently decorative.

My first theory was syntax. HAProxy ACL syntax is a small language with strong opinions, and there's a folk belief (which I held, and which is now recorded here as wrong) that the named-ACL form silently fails in ways the inline anonymous form doesn't. So I rewrote the rules inline:

http-request deny deny_status 403 if !{ hdr(host) -i my.host.example }

Same result. Valid config, still 200 on the bare IP. Now I had two syntaxes failing identically, which is the point where you should stop blaming the syntax.

Reading the error message I already had

Here's the embarrassing part. The tool had been telling me the answer the entire time. Buried in the haproxy -c output, between the notices, was this:

[WARNING]  (49539) : config : 'http-request' rules ignored for frontend 'fe_443'
   as they require HTTP mode.
[WARNING]  (49539) : config : missing timeouts for frontend 'fe_443'.

Ignored for frontend 'fe_443' as they require HTTP mode.

There was no mode http anywhere in the configuration. HAProxy defaults to TCP mode, and in TCP mode it never parses HTTP at all — which means every http-request rule I had written, in every form, in both frontends, was pure decoration. The hostname gate. The geo-block. The ACME carve-out. All of it, the whole security layer, silently skipped while the config validator shrugged and said "valid."

The fix was three lines in the defaults section:

defaults
    mode http
    timeout connect 5s
    timeout client 30s
    timeout server 30s

Reload. Bare IP over HTTP: 403. Bare IP over HTTPS: 403. Real hostname: 200. Four tests, all green.

The second, quieter bug

But the deeper problem was that this frontend also carried a geo-block — an ACL that was supposed to deny everything from outside one country. That rule had been in place for a while, and it had been "verified" once: a request from an allowed source succeeded, therefore the block worked.

Except a never-firing deny looks identical to a working one when you only ever test from inside the allowed set. That verification had proven nothing. The whole time the rule was on disk, nothing outside the region was ever actually blocked — the rule simply didn't exist at runtime, because its frontend was deaf to HTTP.

The rules I keep now

  1. The validator's warnings are the bug report. Configuration file is valid means parseable, not obeyed. Read every warning line before trusting a reload — HAProxy told me, in plain words, that it was ignoring my rules, and I read past it.
  2. Test the deny path, not just the allow path. A request that should be rejected, rejected for the right reason. If you can't source a should-block request, verify the rule fires at all through some other path before believing it.
  3. Two different syntaxes failing identically is a shape, not a coincidence. When your theory requires two independent mechanisms to be broken the same way, the problem is almost certainly one layer below both of them.
  4. Missing mode http in HAProxy disables the entire http-request rule set, silently, with only a warning line to tell you. If you came here from a search engine because your ACLs "don't fire": check the mode first. It's probably this.