caddyhttp: shield specific hostnames from a covering wildcard's client auth (#7920)

* caddyhttp: shield specific hostnames from a covering wildcard's client auth (@sillyzir)

A connection policy for a wildcard hostname (e.g. *.example.com with
client_auth) is first-match by SNI, so it also applied client
authentication to more specific hostnames served by their own site
blocks (public.example.com) — sites that never asked for mTLS.

Two cases produce the shielding empty policy that fixes this:

  - site blocks whose TLS config yields a connection policy with no
    settings (previously discarded as having no effect);
  - site blocks with no TLS connection policy at all — the reported
    case — for which an empty policy is now synthesized.

Either way the empty policy is hoisted directly above the first
client-auth-bearing policy whose wildcard SNI covers the hostname, so
first-match shields it from the client-auth requirement.

Deliberately scoped to client authentication: other wildcard policy
settings, such as certificate selection, are ones a covered hostname
generally WANTS to inherit (see tls_automation_wildcard_shadowing);
sni matchers that fail to decode emit an adapt warning instead of
being silently skipped.

Fixes #7860

* caddyhttp: shield preserves the wildcard policy's other settings (@sillyzir)

The hoisted shield was an empty policy, and connection policies are
first-match: it lifted the client-auth requirement but also suppressed
every other setting the covering wildcard policy carried (certificate
selection, protocol bounds, ALPN). Hoist a copy of the covering policy
with only client_authentication removed instead, so the shielded
hostname keeps inheriting the rest.

The new adapt test gives the wildcard policy protocols and alpn in
addition to client_auth and asserts the shield carries both while
dropping only client authentication; the existing test (client_auth
only) is unchanged, which is exactly why it could not catch this.

* caddyhttp: shield each covering wildcard policy separately (@sillyzir)

The hoisting loop stopped at the first client-auth policy covering any of a
site block's hostnames and gave the shield that block's whole SNI matcher, so
hostnames covered by a *different* wildcard matched it too. Because connection
policies are first-match, those hostnames then took the wrong policy's
settings and lost their own client-authentication requirement entirely.

Map each hostname to the first covering policy individually and hoist one
shield per covering policy, matching only the hostnames it covers. The
existing policies' wildcard SNI names are decoded once up front, and the
shields are inserted back to front so an insertion cannot shift the index of a
covering policy still to be shielded.

* chore: fumpt (@steadytao)
* chore: fix master lint (@steadytao)

---------

Co-authored-by: SillyZir <269283839+SillyZir@users.noreply.github.com>
Co-authored-by: Zen Dodd <mail@steadytao.com>
pull/7916/head^2
SillyZir 2026-08-12 01:09:58 -04:00 committed by GitHub
parent d6f7f18b04
commit 1fed032234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 512 additions and 10 deletions

View File

@ -25,6 +25,7 @@ import (
"strconv"
"strings"
"github.com/caddyserver/certmagic"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
@ -292,7 +293,8 @@ func (st ServerType) Setup(
if filesystems, ok := options["filesystem"].(caddy.Module); ok {
cfg.AppsRaw["caddy.filesystems"] = caddyconfig.JSON(
filesystems,
&warnings)
&warnings,
)
}
if storageCvtr, ok := options["storage"].(caddy.StorageConverter); ok {
@ -719,6 +721,7 @@ func (st *ServerType) serversFromPairings(
})
var hasCatchAllTLSConnPolicy, addressQualifiesForTLS bool
var emptyConnPolicies caddytls.ConnectionPolicies
autoHTTPSWillAddConnPolicy := srv.AutoHTTPS == nil || !srv.AutoHTTPS.Disabled
// if needed, the ServerLogConfig is initialized beforehand so
@ -824,8 +827,25 @@ func (st *ServerType) serversFromPairings(
if !cp.SettingsEmpty() || mapContains(forceAutomatedNames, hosts) {
srv.TLSConnPolicies = append(srv.TLSConnPolicies, cp)
hasCatchAllTLSConnPolicy = len(hosts) == 0
} else if len(hosts) > 0 {
emptyConnPolicies = append(emptyConnPolicies, cp)
}
}
} else if specificHosts := slices.DeleteFunc(
slices.Clone(hosts),
func(h string) bool { return h == "" || strings.Contains(h, "*") },
); len(specificHosts) > 0 {
// site blocks with no TLS connection policy of their own may
// still need an empty policy hoisted above a wildcard policy
// that requires client authentication, or that requirement
// would apply to their more specific hostnames too - see the
// hoisting loop below and issue #7860
slices.Sort(specificHosts)
emptyConnPolicies = append(emptyConnPolicies, &caddytls.ConnectionPolicy{
MatchersRaw: caddy.ModuleMap{
"sni": caddyconfig.JSON(specificHosts, warnings),
},
})
}
for _, addr := range sblock.parsedKeys {
@ -876,7 +896,8 @@ func (st *ServerType) serversFromPairings(
listenerWrapper,
"wrapper",
listenerWrapper.(caddy.Module).CaddyModule().ID.Name(),
warnings)
warnings,
)
srv.ListenerWrappersRaw = append(srv.ListenerWrappersRaw, jsonListenerWrapper)
}
@ -890,7 +911,8 @@ func (st *ServerType) serversFromPairings(
packetConnWrapper,
"wrapper",
packetConnWrapper.(caddy.Module).CaddyModule().ID.Name(),
warnings)
warnings,
)
srv.PacketConnWrappersRaw = append(srv.PacketConnWrappersRaw, jsonPacketConnWrapper)
}
@ -990,6 +1012,72 @@ func (st *ServerType) serversFromPairings(
return nil, err
}
// hostnames whose site blocks configure no TLS settings of their own
// still need shielding if a policy matching a wildcard hostname would
// otherwise impose CLIENT AUTHENTICATION on them. Because connection
// policies are first-match, the shield hoisted above the wildcard
// policy must be a COPY of that policy with only client_authentication
// removed: an empty policy would not just lift the client-auth
// requirement but suppress every other setting the wildcard policy
// carries (certificate selection, protocol bounds, ALPN, ...), which
// the covered hostname does want to inherit - see issue #7860
// the wildcard names each client-auth policy matches, decoded once and
// index-aligned with srv.TLSConnPolicies; nil for a policy that needs
// no client auth or matches no wildcard
clientAuthWildcards := make([][]string, len(srv.TLSConnPolicies))
for i, cp := range srv.TLSConnPolicies {
if cp.ClientAuthentication == nil {
continue
}
names, ok := sniNames(cp, "of existing connection policy ", warnings)
if !ok {
continue
}
clientAuthWildcards[i] = slices.DeleteFunc(names, func(name string) bool {
return !strings.Contains(name, "*")
})
}
// each covering policy needs its OWN shield matching only the
// hostnames it covers: the hostnames of a single site block may be
// covered by DIFFERENT wildcards, and a shield hoisted above one
// policy must not match the hostnames belonging to another, or it
// would lift their client-auth requirement too
shieldedHosts := make([][]string, len(srv.TLSConnPolicies))
for _, ecp := range emptyConnPolicies {
hosts, ok := sniNames(ecp, "", warnings)
if !ok {
continue // no SNI matcher to reason about
}
for _, host := range hosts {
// connection policies are first-match, so only the first
// covering policy is ever reached for this hostname
for i, wildcards := range clientAuthWildcards {
if slices.ContainsFunc(wildcards, func(name string) bool {
return certmagic.MatchWildcard(host, name)
}) {
shieldedHosts[i] = append(shieldedHosts[i], host)
break
}
}
}
}
// hoist the shields last to last, so that inserting one does not
// shift the index of a covering policy still to be shielded
for i, hosts := range slices.Backward(shieldedHosts) {
if len(hosts) == 0 {
continue
}
slices.Sort(hosts)
shield := *srv.TLSConnPolicies[i]
shield.ClientAuthentication = nil
shield.MatchersRaw = caddy.ModuleMap{
"sni": caddyconfig.JSON(slices.Compact(hosts), warnings),
}
srv.TLSConnPolicies = slices.Insert(srv.TLSConnPolicies, i, &shield)
}
// a catch-all TLS conn policy is necessary to ensure TLS can
// be offered to all hostnames of the server; even though only
// one policy is needed to enable TLS for the server, that
@ -1028,6 +1116,25 @@ func (st *ServerType) serversFromPairings(
return servers, nil
}
// sniNames returns the server names a connection policy's sni matcher matches.
// The bool is false when the policy has no sni matcher, or when it does not
// decode - the latter is unexpected enough to warn about rather than silently
// skip, since callers use it to decide whether a hostname needs shielding.
func sniNames(cp *caddytls.ConnectionPolicy, what string, warnings *[]caddyconfig.Warning) ([]string, bool) {
raw, ok := cp.MatchersRaw["sni"]
if !ok {
return nil, false
}
var sni caddytls.MatchServerName
if err := json.Unmarshal(raw, &sni); err != nil {
*warnings = append(*warnings, caddyconfig.Warning{
Message: fmt.Sprintf("decoding sni matcher %swhile checking wildcard coverage: %v", what, err),
})
return nil, false
}
return sni, true
}
func detectConflictingSchemes(srv *caddyhttp.Server, serverBlocks []serverBlock, options map[string]any) error {
httpPort := strconv.Itoa(caddyhttp.DefaultHTTPPort)
if hp, ok := options["http_port"].(int); ok {

View File

@ -0,0 +1,107 @@
*.example.com {
tls {
client_auth {
mode require_and_verify
trust_pool file {
pem_file ../caddy.ca.cer
}
}
}
respond "wildcard"
}
public.example.com {
respond "public"
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"public.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "public",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"*.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "wildcard",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
],
"tls_connection_policies": [
{
"match": {
"sni": [
"public.example.com"
]
}
},
{
"match": {
"sni": [
"*.example.com"
]
},
"client_authentication": {
"ca": {
"pem_files": [
"../caddy.ca.cer"
],
"provider": "file"
},
"mode": "require_and_verify"
}
},
{}
]
}
}
}
}
}

View File

@ -0,0 +1,121 @@
*.example.com {
tls {
protocols tls1.2 tls1.3
alpn h2 http/1.1
client_auth {
mode require_and_verify
trust_pool file {
pem_file ../caddy.ca.cer
}
}
}
respond "wildcard"
}
public.example.com {
respond "public"
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"public.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "public",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"*.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "wildcard",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
],
"tls_connection_policies": [
{
"match": {
"sni": [
"public.example.com"
]
},
"alpn": [
"h2",
"http/1.1"
],
"protocol_min": "tls1.2",
"protocol_max": "tls1.3"
},
{
"match": {
"sni": [
"*.example.com"
]
},
"alpn": [
"h2",
"http/1.1"
],
"protocol_min": "tls1.2",
"protocol_max": "tls1.3",
"client_authentication": {
"ca": {
"pem_files": [
"../caddy.ca.cer"
],
"provider": "file"
},
"mode": "require_and_verify"
}
},
{}
]
}
}
}
}
}

View File

@ -0,0 +1,168 @@
*.example.com {
tls {
client_auth {
mode require_and_verify
trust_pool file {
pem_file ../caddy.ca.cer
}
}
}
respond "example wildcard"
}
*.other.com {
tls {
client_auth {
mode require
trust_pool file {
pem_file ../caddy.ca.cer
}
}
}
respond "other wildcard"
}
public.example.com, public.other.com {
respond "public"
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"public.example.com",
"public.other.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "public",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"*.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "example wildcard",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"*.other.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "other wildcard",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
],
"tls_connection_policies": [
{
"match": {
"sni": [
"public.example.com"
]
}
},
{
"match": {
"sni": [
"*.example.com"
]
},
"client_authentication": {
"ca": {
"pem_files": [
"../caddy.ca.cer"
],
"provider": "file"
},
"mode": "require_and_verify"
}
},
{
"match": {
"sni": [
"public.other.com"
]
}
},
{
"match": {
"sni": [
"*.other.com"
]
},
"client_authentication": {
"ca": {
"pem_files": [
"../caddy.ca.cer"
],
"provider": "file"
},
"mode": "require"
}
},
{}
]
}
}
}
}
}

View File

@ -436,8 +436,7 @@ func (s *Server) provisionUnderscoreHeaders() error {
return nil
}
var err error
s.underscoreExactAllow, s.underscoreExactDrop, s.underscorePrefixRules, err =
provisionHeaderAliasAllowlist(s.ExpectedUnderscoreHeaders, '_', "expected_underscore_headers")
s.underscoreExactAllow, s.underscoreExactDrop, s.underscorePrefixRules, err = provisionHeaderAliasAllowlist(s.ExpectedUnderscoreHeaders, '_', "expected_underscore_headers")
return err
}
@ -449,8 +448,7 @@ func (s *Server) provisionDotHeaders() error {
return nil
}
var err error
s.dotExactAllow, s.dotExactDrop, s.dotPrefixRules, err =
provisionHeaderAliasAllowlist(s.ExpectedDotHeaders, '.', "expected_dot_headers")
s.dotExactAllow, s.dotExactDrop, s.dotPrefixRules, err = provisionHeaderAliasAllowlist(s.ExpectedDotHeaders, '.', "expected_dot_headers")
return err
}
@ -905,7 +903,7 @@ func (s *Server) findLastRouteWithHostMatcher() int {
for i, route := range s.Routes {
// since we want to break out of an inner loop, use a closure
// to allow us to use 'return' when we found a host matcher
found := (func() bool {
found := func() bool {
for _, sets := range route.MatcherSets {
for _, matcher := range sets {
switch matcher.(type) {
@ -916,7 +914,7 @@ func (s *Server) findLastRouteWithHostMatcher() int {
}
}
return false
})()
}()
// if we found the host matcher, change the lastIndex to
// just after the current route
@ -1164,7 +1162,8 @@ func (s *Server) logRequest(
fieldCount := 6
fields = make([]zapcore.Field, 0, fieldCount+len(extra.fields))
fields = append(fields,
fields = append(
fields,
zap.Int("bytes_read", reqBodyLength),
zap.String("user_id", userID),
zap.Duration("duration", *duration),