Does it make sense to offer finer control for strict_sni_host?

This is a discussion topic.

Caddy recently added an option to request wildcards instead of individual certs. This feature in combination with strict_sni_host=true flag causes problems.

Firefox & Chrome try to use a connection setup for a.home.arpa for a request to b.home.arpa. The certificate in use is valid for *.home.arpa and strict_sni_host is set to true. In this situation, The browser receives a 421 Misdirected Request message.

I read the RFC and in this situation, the HTTP client may retry request with a fresh connection but the browsers don’t seem to be doing that. Instead, It stays at the 421 Misdirect Request response page until I hit refresh and then it works.

Why do I want strict_sni_host=true ?

I like this flag a lot because it keeps junk easily filterable / outside of my logs. I do not want to see traffic from bots just fishing for things. (I also like default_sni=null!)

My suggestion:-

Can we modify strict_sni_host to be no, loose, yes ?

  • no => maps to the false behavior right now.
  • yes => maps to the true behavior right now.
  • loose => allow any host configured in that Caddy proxy instance.

So, strict_sni_host=loose will allow a request to b.home.arpa done over a connection setup for a.home.arpa because both a.home.arpa and b.home.arpa are configured in the same proxy config.

I have seen a fair bit of 421 responses since I configured wildcards certificates for my homelab and a number of API failures. I have not seen any HTTP clients that actually immediately retry a request with a new connection. Most seem to kill the connection and setup a new one whenever they need to access the service again.

I’ll let smarter folks respond to the rest of your post, but if you’re mainly interested in keeping your logs clean, you can get a similar result with something like this:

{
	auto_https disable_redirects
}

*.example.com {
	tls internal
	abort
}

foo.example.com {
	log
	respond "Foo Site"
}

http://foo.example.com {
	redir https://{host}{uri} 308
}

http:// {
	abort
}
  • auto_https disable_redirects disables automatic HTTP-to-HTTPS redirects

  • The *.example.com block is just for obtaining the wildcard certificate. It doesn’t serve any data. I’m using tls internal here as an example. Use whatever tls directive you need.

  • foo.example.com is the only site actually serving content and logging requests.

  • The http://foo.example.com block (optional) handles HTTP-to-HTTPS redirection for foo.example.com. You can skip it if you don’t need that redirect.

  • The final http:// block drops all other HTTP traffic.

With this setup, unless someone specifically knows about foo.example.com, they won’t get anything meaningful from the server and your logs stay clean.

$ curl http://example.com
curl: (52) Empty reply from server

$ curl https://example.com
curl: (35) TLS connect error: error:0A000438:SSL routines::tlsv1 alert internal error

$ curl http://foo.example.com -I
HTTP/1.1 308 Permanent Redirect
Location: https://foo.example.com/
Server: Caddy
Date: Sun, 08 Feb 2026 04:35:28 GMT

$ curl https://foo.example.com
Foo Site

$ curl http://bar.example.com
curl: (52) Empty reply from server

$ curl https://bar.example.com
curl: (92) HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR (err 2)

$ curl http://localhost
curl: (52) Empty reply from server

$ curl https://localhost
curl: (35) TLS connect error: error:0A000438:SSL routines::tlsv1 alert internal error

The only one that ends up in the logs is:

curl https://foo.example.com

I’ve actually been using this setup well before Caddy 2.10.x. Back then, to get the same result, the config looked like this:

{
	auto_https disable_redirects
}

*.example.com {
	tls internal
	log

	@foo header host foo.example.com
	handle @foo {
		respond "Foo Site"
	}

	handle {
		log_skip
		abort
	}
}

http://foo.example.com {
	redir https://{host}{uri} 308
}

http:// {
	abort
}

This would serve and log only foo.example.com, while dropping everything else. The handle block catches unmatched requests and prevents log clutter, and the wildcard cert covers the domain.

3 Likes

So what you’re saying is:

Browser creates a TLS connection and the server uses a cert for *.home.arpa`, then makes a request for a.home.arpa. Then, it makes another request over the same connection for b.home.arpa and Caddy rejects that request with 421?

The 421 should only happen if the HTTP Host header does not match the TLS ServerName (SNI). The cert should have nothing to do with it.

From a security standpoint, your proposed “loose” is the same as “false” (no strict_sni_host) today.

Yess

I may have a misunderstanding here so I’ll clarify.

The tls setup was initially done with sni=a.home.arpa and then it used the host=a.home.arpa to access that service. Now it’s reusing the existing tls connection so it’s not going to send a new sni(or will it?) which means now it’s sending a request with host=b.home.arpa on a connection that was setup with sni=a.home.arpa. this is the situation where i see a lot of 421 responses.

Okay. My understanding was caddy will allow a complete mismatch between sni and host with strict_sni_host set to false. So as an example, it’ll allow a request with sni=a.home.arpa with host=example.com even though there is no example.com configured.

1 Like

What do you mean allow? Would what it serve?

1 Like

If you have two sites (different domains, or subdomains), and one of them relies on TLS client auth to protect it, it’s a security vuln to allow a client to connect using the SNI of the unprotected domain, then send requests with a Host header of the protected domain over the same TLS connection.

The name on the cert doesn’t matter; the connection is established either way. The security boundary isn’t related to the cert. It’s in the HTTP(S) server config.

So, the browser will have to reconnect in this case, it’s a bug if they don’t.

Got it, thank you for explaining!

In that case, I need to investigate why are there so many 421 responses since I switched to using wildcard certificates.

1 Like

I saw cloudflared doing something weird like this when multiple hostnames landed at the same tunnel - it would forward them to localhost:443 on the same HTTP/2 connection even with different hostnames with different certs. In this case I was using Apache httpd 2.4, which was returning 421.

I haven’t deployed this yet so I’m not sure I’ve completely solved it, but adding all the hosts onto a single cert seemed to help. Apache’s SNI/hostname checking (SSLVHostSNIPolicy) seems to default to relaxed like caddy’s strict_sni_host.

Okay so um, I realize I am nitpicking and for my original problem of not getting spam/garbage in my logs, I have other options. I don’t really want to implement that. With that out of the way, here are some observations.

I am talking about just normal HTTP requests, not TLS client auth/mTLS.

I have two hosts for my test, auth.ishanjain.me & jellyfin.ishanjain.me.

With the included code at the bottom, here is what I see

  • with strict_sni_host=true
    → Response 1 from auth.ishanjain.me: 200 OK
    → Response 2 from jellyfin.ishanjain.me: 421 Misdirected Request
  • with strict_sni_host=false
    → Response 1 from auth.ishanjain.me: 200 OK
    → Response 2 from jellyfin.ishanjain.me: 302 Found
  • after adding the line req2.Host = "example.com" before doing client.Do(req2) where example.com is not configured in my caddy instance and strict_sni_host=true
    → Response 1 from auth.ishanjain.me: 200 OK
    → Response 2 from jellyfin.ishanjain.me: 421 Misdirected Request
  • after adding the line req2.Host = "example.com" before doing client.Do(req2) where example.com is not configured in my caddy instance and strict_sni_host=false
    → Response 1 from auth.ishanjain.me: 200 OK
    → Response 2 from jellyfin.ishanjain.me: 404 Not Found

(In the last 2 examples, the response 2 says jellyfin… but the hostname was changed to example.com)

So to summarize,

  • strict_sni_host=true is really strict, it allows the exact match and no other hostname.
  • strict_sni_host=false completely disables the feature and it’ll allow anything at all! In my example, example.com was not configured on the proxy so I got 404 but if I add a config for example.com, It’ll hit that subroute.
  • What I am asking for is a option in the middle called loose. It should allow any hostname configured in the proxy and every thing else should be served with a 421.

I want to keep strict_sni_host=true because I like it but the 421s are a problem with wildcard certificates and I see a large number of them being served to people who use my homelab. :frowning:

And I see these frequently because I run authelia at auth.domain.com for services on the same domain so people open service.domain.com, redirected to auth.domain.com but instead of seeing the auth page, they get 421!

The program for testing:-

package main

import (
	"crypto/tls"
	"fmt"
	"log"
	"net/http"
	"net/http/httputil"
)

func main() {
	authHost := "auth.ishanjain.me"
	jfHost := "jellyfin.ishanjain.me"

	tlsConfig := &tls.Config{
		ServerName: authHost,
	}

	conn, err := tls.Dial("tcp", authHost + ":443", tlsConfig)
	if err != nil {
		log.Fatalf("Failed to dial and establish TLS connection: %v", err)
	}
	defer conn.Close()

	clientConn := httputil.NewClientConn(conn, nil)
	defer clientConn.Close()

	req1, err := http.NewRequest("GET", "https://"+authHost, nil)
	if err != nil {
		log.Fatalf("Failed to create first request: %v", err)
	}

	resp1, err := clientConn.Do(req1)
	if err != nil {
		log.Fatalf("Failed to do first request: %v", err)
	}
	defer resp1.Body.Close()

	fmt.Printf("--> Response 1 from %s: %s\n", authHost, resp1.Status)

	req2, err := http.NewRequest("GET", "https://"+jfHost, nil)
	if err != nil {
		log.Fatalf("Failed to create second request: %v", err)
	}

	resp2, err := clientConn.Do(req2)
	if err != nil {
		log.Fatalf("Failed to do second request: %v", err)
	}
	defer resp2.Body.Close()

	fmt.Printf("--> Response 2 from %s: %s\n", jfHost, resp2.Status)
}