Handle_errors not working for Bad requests (error 400)

1. The problem I’m having:

When intentionally (or by annoying scraper bots) causing a 400 Bad request error, by:

  • requesting a http page on the https port , causing a Client sent an HTTP request to an HTTPS server. ( curl -vL http://localhost:443)
  • requesting a page without a host header, causing a 400 Bad Request: missing required Host header (curl -vL -H "Host:" http://localhost)

while having a handle_errors directive, the handle_errors directive seems to never pick up/detect the error 400, and nothing inside it is executed (no respond, no abort, etc.)

2. Error messages and/or full log output:

When requesting a http page on the https port ( curl -vL http://localhost:443) :

caddy  | {"level":"debug","ts":1775073325.9619644,"logger":"http.stdlib","msg":"http: TLS handshake error from [::1]:54930: client sent an HTTP request to an HTTPS server"}

When requesting a page without a host header (curl -vL -H "Host:" http://localhost) :

No logs were generated, even with debug in the global options

3. Caddy version:

v2.11.2 h1:iOlpsSiSKqEW+SIXrcZsZ/NO74SzB/ycqqvAIEfIm64=

4. How I installed and ran Caddy:

Caddy was installed and ran with docker compose, relevant docker-compose.yml and Caddyfile are below in their relevant sections

a. System environment:

Docker version 29.3.1, build c2be9ccfc3 running on EndeavorOS (Arch linux fork)

b. Command:

Not relevant, docker compose is used.

c. Service/unit/compose file:

docker-compose.yml:

services:
  caddy:
    image: caddy:2.11.2-alpine
    container_name: caddy
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./conf:/etc/caddy
      - ./data:/data
      - ./caddy_config:/config

d. My complete Caddy config:

Caddyfile:

{
        auto_https disable_redirects
        skip_install_trust
        debug
}

http:// {
        handle_errors 400 {
                respond "Nope" 418
        }
        redir https://{host}{uri} 301
}

localhost {
        handle_errors 400 {
                respond "Nope" 418
        }
        respond "Yay!"
}

5. Links to relevant resources:

To not waste the human maintainers’ time, i ran this issue through an LLM which had access to caddy’s source code, to actually investigate the issue rather then just hallucinating a response, and it came up with the following answer (actually very useful but slightly long):

LLM response

No, handle_errors cannot intercept that 400 error.

Here’s why, broken down by where each thing lives in the code:


Where handle_errors hooks in

handle_errors (backed by the errorHandlerChain) is only triggered when Caddy’s own primary HTTP handler chain returns an error. In Server.ServeHTTP, the flow is:

  1. Call s.serveHTTP(w, r), which calls s.primaryHandlerChain.ServeHTTP(w, r)

  2. Only if that returns a non-nil error does Caddy enrich the request with error context and invoke s.errorHandlerChain.ServeHTTP(w, r) (your handle_errors routes). server.go:379-412


Where the “Client sent an HTTP request to an HTTPS server” error comes from

This error is not generated by Caddy’s handler chain at all. It is generated deep inside Go’s standard library (crypto/tls + net/http) when the TLS handshake fails because the incoming bytes look like plain HTTP, not a TLS record. This happens at the raw TCP connection layer, before Server.ServeHTTP is ever called. There is no trace of this message anywhere in Caddy’s own source code.

Because the response is written directly by the Go runtime’s TLS/HTTP server internals — never passing through s.serveHTTP() — the error never bubbles up into the handler chain, and handle_errors has no opportunity to intercept it.


The actual solution: HTTPRedirectListenerWrapper

Caddy provides HTTPRedirectListenerWrapper (module ID: caddy.listeners.http_redirect) specifically to address this problem. It operates at the listener level — before TLS even attempts its handshake — by peeking at the first 5 bytes of the connection to detect plain HTTP traffic. httpredirectlistener.go:33-50

If it detects HTTP bytes (GET /, HEAD , POST , PUT /, OPTIO), it reads the full request and directly writes a 308 Permanent Redirect to https:// on the raw connection: httpredirectlistener.go:106-158

Critically, this also bypasses the HTTP handler chain entirely — it writes the redirect response straight to the net.Conn. So handle_errors cannot intercept this redirect either. It’s a raw TCP-level mechanism, not an HTTP middleware mechanism.

To enable it, place it before the tls listener wrapper in your server config:

{
  servers :443 {  

    listener_wrappers {  

      http_redirect  

      tls  

    }  

  }  

}

Now, if the machine-generated answer is to be believed, and the error is generated inside Go’s standard library, which does not call handle_errors, the only option to modify Caddy’s behavior is through the http_redirect listen wrapper, which however only does a 308 redirect and offers nowhere near the level of customization that handle_errors provides, which i would like much more.

Is this edge case an intended feature/oversight or simply a misconfiguration on my end?

thanks in advance,

-A happy caddy user

These are triggered before any HTTP handling runs, so there’s no way currently to change the behaviour there.

This is harmless, it’s just a failed connection that dies immediately.

Just don’t keep on debug in global options if you’re bothered by the noise.

3 Likes

thank you for clarifying, i thought i had missed some option that could change it. Currently http_redirect which is the only way to change this behavior does not have any customization like handle_errors does.

Yeah i already got that part, i was interested in aborting the response entirely to combat the massive amount of bots that hit my puny raspberry pi server each hour, saturating it’s resources and my ADSL upload speeds

and don’t worry, debug was only on the test instance for this bug report, but i’m not concerned about log volume anyways, i just want to nuke “unnecessary” (to real users) connections