@marcel91200 / caddy Mirror · read-only / commits / 1fed0322
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 07:15 UTC

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>

SillyZir committed Aug 12, 2026 at 01:09 UTC 1fed032234ecc85c6cbab681e5c4fc231ea477b1
5 files changed +512 -10
caddyconfig/httpcaddyfile/httptype.go
+110 -3
@@ -25,6 +25,7 @@ import (
25 "strconv"
26 "strings"
27
28 + "github.com/caddyserver/certmagic"
29 "go.uber.org/zap"
30
31 "github.com/caddyserver/caddy/v2"
@@ -292,7 +293,8 @@ func (st ServerType) Setup(
293 if filesystems, ok := options["filesystem"].(caddy.Module); ok {
294 cfg.AppsRaw["caddy.filesystems"] = caddyconfig.JSON(
295 filesystems,
295 - &warnings)
296 + &warnings,
297 + )
298 }
299
300 if storageCvtr, ok := options["storage"].(caddy.StorageConverter); ok {
@@ -719,6 +721,7 @@ func (st *ServerType) serversFromPairings(
721 })
722
723 var hasCatchAllTLSConnPolicy, addressQualifiesForTLS bool
724 + var emptyConnPolicies caddytls.ConnectionPolicies
725 autoHTTPSWillAddConnPolicy := srv.AutoHTTPS == nil || !srv.AutoHTTPS.Disabled
726
727 // if needed, the ServerLogConfig is initialized beforehand so
@@ -824,8 +827,25 @@ func (st *ServerType) serversFromPairings(
827 if !cp.SettingsEmpty() || mapContains(forceAutomatedNames, hosts) {
828 srv.TLSConnPolicies = append(srv.TLSConnPolicies, cp)
829 hasCatchAllTLSConnPolicy = len(hosts) == 0
830 + } else if len(hosts) > 0 {
831 + emptyConnPolicies = append(emptyConnPolicies, cp)
832 }
833 }
834 + } else if specificHosts := slices.DeleteFunc(
835 + slices.Clone(hosts),
836 + func(h string) bool { return h == "" || strings.Contains(h, "*") },
837 + ); len(specificHosts) > 0 {
838 + // site blocks with no TLS connection policy of their own may
839 + // still need an empty policy hoisted above a wildcard policy
840 + // that requires client authentication, or that requirement
841 + // would apply to their more specific hostnames too - see the
842 + // hoisting loop below and issue #7860
843 + slices.Sort(specificHosts)
844 + emptyConnPolicies = append(emptyConnPolicies, &caddytls.ConnectionPolicy{
845 + MatchersRaw: caddy.ModuleMap{
846 + "sni": caddyconfig.JSON(specificHosts, warnings),
847 + },
848 + })
849 }
850
851 for _, addr := range sblock.parsedKeys {
@@ -876,7 +896,8 @@ func (st *ServerType) serversFromPairings(
896 listenerWrapper,
897 "wrapper",
898 listenerWrapper.(caddy.Module).CaddyModule().ID.Name(),
879 - warnings)
899 + warnings,
900 + )
901 srv.ListenerWrappersRaw = append(srv.ListenerWrappersRaw, jsonListenerWrapper)
902 }
903
@@ -890,7 +911,8 @@ func (st *ServerType) serversFromPairings(
911 packetConnWrapper,
912 "wrapper",
913 packetConnWrapper.(caddy.Module).CaddyModule().ID.Name(),
893 - warnings)
914 + warnings,
915 + )
916 srv.PacketConnWrappersRaw = append(srv.PacketConnWrappersRaw, jsonPacketConnWrapper)
917 }
918
@@ -990,6 +1012,72 @@ func (st *ServerType) serversFromPairings(
1012 return nil, err
1013 }
1014
1015 + // hostnames whose site blocks configure no TLS settings of their own
1016 + // still need shielding if a policy matching a wildcard hostname would
1017 + // otherwise impose CLIENT AUTHENTICATION on them. Because connection
1018 + // policies are first-match, the shield hoisted above the wildcard
1019 + // policy must be a COPY of that policy with only client_authentication
1020 + // removed: an empty policy would not just lift the client-auth
1021 + // requirement but suppress every other setting the wildcard policy
1022 + // carries (certificate selection, protocol bounds, ALPN, ...), which
1023 + // the covered hostname does want to inherit - see issue #7860
1024 + // the wildcard names each client-auth policy matches, decoded once and
1025 + // index-aligned with srv.TLSConnPolicies; nil for a policy that needs
1026 + // no client auth or matches no wildcard
1027 + clientAuthWildcards := make([][]string, len(srv.TLSConnPolicies))
1028 + for i, cp := range srv.TLSConnPolicies {
1029 + if cp.ClientAuthentication == nil {
1030 + continue
1031 + }
1032 + names, ok := sniNames(cp, "of existing connection policy ", warnings)
1033 + if !ok {
1034 + continue
1035 + }
1036 + clientAuthWildcards[i] = slices.DeleteFunc(names, func(name string) bool {
1037 + return !strings.Contains(name, "*")
1038 + })
1039 + }
1040 +
1041 + // each covering policy needs its OWN shield matching only the
1042 + // hostnames it covers: the hostnames of a single site block may be
1043 + // covered by DIFFERENT wildcards, and a shield hoisted above one
1044 + // policy must not match the hostnames belonging to another, or it
1045 + // would lift their client-auth requirement too
1046 + shieldedHosts := make([][]string, len(srv.TLSConnPolicies))
1047 + for _, ecp := range emptyConnPolicies {
1048 + hosts, ok := sniNames(ecp, "", warnings)
1049 + if !ok {
1050 + continue // no SNI matcher to reason about
1051 + }
1052 + for _, host := range hosts {
1053 + // connection policies are first-match, so only the first
1054 + // covering policy is ever reached for this hostname
1055 + for i, wildcards := range clientAuthWildcards {
1056 + if slices.ContainsFunc(wildcards, func(name string) bool {
1057 + return certmagic.MatchWildcard(host, name)
1058 + }) {
1059 + shieldedHosts[i] = append(shieldedHosts[i], host)
1060 + break
1061 + }
1062 + }
1063 + }
1064 + }
1065 +
1066 + // hoist the shields last to last, so that inserting one does not
1067 + // shift the index of a covering policy still to be shielded
1068 + for i, hosts := range slices.Backward(shieldedHosts) {
1069 + if len(hosts) == 0 {
1070 + continue
1071 + }
1072 + slices.Sort(hosts)
1073 + shield := *srv.TLSConnPolicies[i]
1074 + shield.ClientAuthentication = nil
1075 + shield.MatchersRaw = caddy.ModuleMap{
1076 + "sni": caddyconfig.JSON(slices.Compact(hosts), warnings),
1077 + }
1078 + srv.TLSConnPolicies = slices.Insert(srv.TLSConnPolicies, i, &shield)
1079 + }
1080 +
1081 // a catch-all TLS conn policy is necessary to ensure TLS can
1082 // be offered to all hostnames of the server; even though only
1083 // one policy is needed to enable TLS for the server, that
@@ -1028,6 +1116,25 @@ func (st *ServerType) serversFromPairings(
1116 return servers, nil
1117 }
1118
1119 +// sniNames returns the server names a connection policy's sni matcher matches.
1120 +// The bool is false when the policy has no sni matcher, or when it does not
1121 +// decode - the latter is unexpected enough to warn about rather than silently
1122 +// skip, since callers use it to decide whether a hostname needs shielding.
1123 +func sniNames(cp *caddytls.ConnectionPolicy, what string, warnings *[]caddyconfig.Warning) ([]string, bool) {
1124 + raw, ok := cp.MatchersRaw["sni"]
1125 + if !ok {
1126 + return nil, false
1127 + }
1128 + var sni caddytls.MatchServerName
1129 + if err := json.Unmarshal(raw, &sni); err != nil {
1130 + *warnings = append(*warnings, caddyconfig.Warning{
1131 + Message: fmt.Sprintf("decoding sni matcher %swhile checking wildcard coverage: %v", what, err),
1132 + })
1133 + return nil, false
1134 + }
1135 + return sni, true
1136 +}
1137 +
1138 func detectConflictingSchemes(srv *caddyhttp.Server, serverBlocks []serverBlock, options map[string]any) error {
1139 httpPort := strconv.Itoa(caddyhttp.DefaultHTTPPort)
1140 if hp, ok := options["http_port"].(int); ok {
caddytest/integration/caddyfile_adapt/tls_client_auth_wildcard_not_inherited_by_specific_host.caddyfiletest new
+107
@@ -0,0 +1,107 @@
1 +*.example.com {
2 + tls {
3 + client_auth {
4 + mode require_and_verify
5 + trust_pool file {
6 + pem_file ../caddy.ca.cer
7 + }
8 + }
9 + }
10 + respond "wildcard"
11 +}
12 +
13 +public.example.com {
14 + respond "public"
15 +}
16 +----------
17 +{
18 + "apps": {
19 + "http": {
20 + "servers": {
21 + "srv0": {
22 + "listen": [
23 + ":443"
24 + ],
25 + "routes": [
26 + {
27 + "match": [
28 + {
29 + "host": [
30 + "public.example.com"
31 + ]
32 + }
33 + ],
34 + "handle": [
35 + {
36 + "handler": "subroute",
37 + "routes": [
38 + {
39 + "handle": [
40 + {
41 + "body": "public",
42 + "handler": "static_response"
43 + }
44 + ]
45 + }
46 + ]
47 + }
48 + ],
49 + "terminal": true
50 + },
51 + {
52 + "match": [
53 + {
54 + "host": [
55 + "*.example.com"
56 + ]
57 + }
58 + ],
59 + "handle": [
60 + {
61 + "handler": "subroute",
62 + "routes": [
63 + {
64 + "handle": [
65 + {
66 + "body": "wildcard",
67 + "handler": "static_response"
68 + }
69 + ]
70 + }
71 + ]
72 + }
73 + ],
74 + "terminal": true
75 + }
76 + ],
77 + "tls_connection_policies": [
78 + {
79 + "match": {
80 + "sni": [
81 + "public.example.com"
82 + ]
83 + }
84 + },
85 + {
86 + "match": {
87 + "sni": [
88 + "*.example.com"
89 + ]
90 + },
91 + "client_authentication": {
92 + "ca": {
93 + "pem_files": [
94 + "../caddy.ca.cer"
95 + ],
96 + "provider": "file"
97 + },
98 + "mode": "require_and_verify"
99 + }
100 + },
101 + {}
102 + ]
103 + }
104 + }
105 + }
106 + }
107 +}
caddytest/integration/caddyfile_adapt/tls_client_auth_wildcard_shield_preserves_other_settings.caddyfiletest new
+121
@@ -0,0 +1,121 @@
1 +*.example.com {
2 + tls {
3 + protocols tls1.2 tls1.3
4 + alpn h2 http/1.1
5 + client_auth {
6 + mode require_and_verify
7 + trust_pool file {
8 + pem_file ../caddy.ca.cer
9 + }
10 + }
11 + }
12 + respond "wildcard"
13 +}
14 +
15 +public.example.com {
16 + respond "public"
17 +}
18 +----------
19 +{
20 + "apps": {
21 + "http": {
22 + "servers": {
23 + "srv0": {
24 + "listen": [
25 + ":443"
26 + ],
27 + "routes": [
28 + {
29 + "match": [
30 + {
31 + "host": [
32 + "public.example.com"
33 + ]
34 + }
35 + ],
36 + "handle": [
37 + {
38 + "handler": "subroute",
39 + "routes": [
40 + {
41 + "handle": [
42 + {
43 + "body": "public",
44 + "handler": "static_response"
45 + }
46 + ]
47 + }
48 + ]
49 + }
50 + ],
51 + "terminal": true
52 + },
53 + {
54 + "match": [
55 + {
56 + "host": [
57 + "*.example.com"
58 + ]
59 + }
60 + ],
61 + "handle": [
62 + {
63 + "handler": "subroute",
64 + "routes": [
65 + {
66 + "handle": [
67 + {
68 + "body": "wildcard",
69 + "handler": "static_response"
70 + }
71 + ]
72 + }
73 + ]
74 + }
75 + ],
76 + "terminal": true
77 + }
78 + ],
79 + "tls_connection_policies": [
80 + {
81 + "match": {
82 + "sni": [
83 + "public.example.com"
84 + ]
85 + },
86 + "alpn": [
87 + "h2",
88 + "http/1.1"
89 + ],
90 + "protocol_min": "tls1.2",
91 + "protocol_max": "tls1.3"
92 + },
93 + {
94 + "match": {
95 + "sni": [
96 + "*.example.com"
97 + ]
98 + },
99 + "alpn": [
100 + "h2",
101 + "http/1.1"
102 + ],
103 + "protocol_min": "tls1.2",
104 + "protocol_max": "tls1.3",
105 + "client_authentication": {
106 + "ca": {
107 + "pem_files": [
108 + "../caddy.ca.cer"
109 + ],
110 + "provider": "file"
111 + },
112 + "mode": "require_and_verify"
113 + }
114 + },
115 + {}
116 + ]
117 + }
118 + }
119 + }
120 + }
121 +}
caddytest/integration/caddyfile_adapt/tls_client_auth_wildcards_shield_each_covering_policy.caddyfiletest new
+168
@@ -0,0 +1,168 @@
1 +*.example.com {
2 + tls {
3 + client_auth {
4 + mode require_and_verify
5 + trust_pool file {
6 + pem_file ../caddy.ca.cer
7 + }
8 + }
9 + }
10 + respond "example wildcard"
11 +}
12 +
13 +*.other.com {
14 + tls {
15 + client_auth {
16 + mode require
17 + trust_pool file {
18 + pem_file ../caddy.ca.cer
19 + }
20 + }
21 + }
22 + respond "other wildcard"
23 +}
24 +
25 +public.example.com, public.other.com {
26 + respond "public"
27 +}
28 +----------
29 +{
30 + "apps": {
31 + "http": {
32 + "servers": {
33 + "srv0": {
34 + "listen": [
35 + ":443"
36 + ],
37 + "routes": [
38 + {
39 + "match": [
40 + {
41 + "host": [
42 + "public.example.com",
43 + "public.other.com"
44 + ]
45 + }
46 + ],
47 + "handle": [
48 + {
49 + "handler": "subroute",
50 + "routes": [
51 + {
52 + "handle": [
53 + {
54 + "body": "public",
55 + "handler": "static_response"
56 + }
57 + ]
58 + }
59 + ]
60 + }
61 + ],
62 + "terminal": true
63 + },
64 + {
65 + "match": [
66 + {
67 + "host": [
68 + "*.example.com"
69 + ]
70 + }
71 + ],
72 + "handle": [
73 + {
74 + "handler": "subroute",
75 + "routes": [
76 + {
77 + "handle": [
78 + {
79 + "body": "example wildcard",
80 + "handler": "static_response"
81 + }
82 + ]
83 + }
84 + ]
85 + }
86 + ],
87 + "terminal": true
88 + },
89 + {
90 + "match": [
91 + {
92 + "host": [
93 + "*.other.com"
94 + ]
95 + }
96 + ],
97 + "handle": [
98 + {
99 + "handler": "subroute",
100 + "routes": [
101 + {
102 + "handle": [
103 + {
104 + "body": "other wildcard",
105 + "handler": "static_response"
106 + }
107 + ]
108 + }
109 + ]
110 + }
111 + ],
112 + "terminal": true
113 + }
114 + ],
115 + "tls_connection_policies": [
116 + {
117 + "match": {
118 + "sni": [
119 + "public.example.com"
120 + ]
121 + }
122 + },
123 + {
124 + "match": {
125 + "sni": [
126 + "*.example.com"
127 + ]
128 + },
129 + "client_authentication": {
130 + "ca": {
131 + "pem_files": [
132 + "../caddy.ca.cer"
133 + ],
134 + "provider": "file"
135 + },
136 + "mode": "require_and_verify"
137 + }
138 + },
139 + {
140 + "match": {
141 + "sni": [
142 + "public.other.com"
143 + ]
144 + }
145 + },
146 + {
147 + "match": {
148 + "sni": [
149 + "*.other.com"
150 + ]
151 + },
152 + "client_authentication": {
153 + "ca": {
154 + "pem_files": [
155 + "../caddy.ca.cer"
156 + ],
157 + "provider": "file"
158 + },
159 + "mode": "require"
160 + }
161 + },
162 + {}
163 + ]
164 + }
165 + }
166 + }
167 + }
168 +}
modules/caddyhttp/server.go
+6 -7
@@ -436,8 +436,7 @@ func (s *Server) provisionUnderscoreHeaders() error {
436 return nil
437 }
438 var err error
439 - s.underscoreExactAllow, s.underscoreExactDrop, s.underscorePrefixRules, err =
440 - provisionHeaderAliasAllowlist(s.ExpectedUnderscoreHeaders, '_', "expected_underscore_headers")
439 + s.underscoreExactAllow, s.underscoreExactDrop, s.underscorePrefixRules, err = provisionHeaderAliasAllowlist(s.ExpectedUnderscoreHeaders, '_', "expected_underscore_headers")
440 return err
441 }
442
@@ -449,8 +448,7 @@ func (s *Server) provisionDotHeaders() error {
448 return nil
449 }
450 var err error
452 - s.dotExactAllow, s.dotExactDrop, s.dotPrefixRules, err =
453 - provisionHeaderAliasAllowlist(s.ExpectedDotHeaders, '.', "expected_dot_headers")
451 + s.dotExactAllow, s.dotExactDrop, s.dotPrefixRules, err = provisionHeaderAliasAllowlist(s.ExpectedDotHeaders, '.', "expected_dot_headers")
452 return err
453 }
454
@@ -905,7 +903,7 @@ func (s *Server) findLastRouteWithHostMatcher() int {
903 for i, route := range s.Routes {
904 // since we want to break out of an inner loop, use a closure
905 // to allow us to use 'return' when we found a host matcher
908 - found := (func() bool {
906 + found := func() bool {
907 for _, sets := range route.MatcherSets {
908 for _, matcher := range sets {
909 switch matcher.(type) {
@@ -916,7 +914,7 @@ func (s *Server) findLastRouteWithHostMatcher() int {
914 }
915 }
916 return false
919 - })()
917 + }()
918
919 // if we found the host matcher, change the lastIndex to
920 // just after the current route
@@ -1164,7 +1162,8 @@ func (s *Server) logRequest(
1162
1163 fieldCount := 6
1164 fields = make([]zapcore.Field, 0, fieldCount+len(extra.fields))
1167 - fields = append(fields,
1165 + fields = append(
1166 + fields,
1167 zap.Int("bytes_read", reqBodyLength),
1168 zap.String("user_id", userID),
1169 zap.Duration("duration", *duration),