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, 19:45 UTC
metrics: resolve per-host inifinite cardinality (#7306)
Mohammed Al Sahaf committed
Oct 21, 2025 at 09:06 UTC
595aab8bc0d98c510c044a2a6b8e7053aaf4e8fb
3 files changed
+231
-7
modules/caddyhttp/app.go
+2
@@ -198,6 +198,8 @@ func (app *App) Provision(ctx caddy.Context) error {
198
if app.Metrics != nil {
199
app.Metrics.init = sync.Once{}
200
app.Metrics.httpMetrics = &httpMetrics{}
201
+ // Scan config for allowed hosts to prevent cardinality explosion
202
+ app.Metrics.scanConfigForHosts(app)
203
}
204
// prepare each server
205
oldContext := ctx.Context
modules/caddyhttp/metrics.go
+117
-4
@@ -17,14 +17,60 @@ import (
17
18
// Metrics configures metrics observations.
19
// EXPERIMENTAL and subject to change or removal.
20
+//
21
+// Example configuration:
22
+//
23
+// {
24
+// "apps": {
25
+// "http": {
26
+// "metrics": {
27
+// "per_host": true,
28
+// "allow_catch_all_hosts": false
29
+// },
30
+// "servers": {
31
+// "srv0": {
32
+// "routes": [{
33
+// "match": [{"host": ["example.com", "www.example.com"]}],
34
+// "handle": [{"handler": "static_response", "body": "Hello"}]
35
+// }]
36
+// }
37
+// }
38
+// }
39
+// }
40
+// }
41
+//
42
+// In this configuration:
43
+// - Requests to example.com and www.example.com get individual host labels
44
+// - All other hosts (e.g., attacker.com) are aggregated under "_other" label
45
+// - This prevents unlimited cardinality from arbitrary Host headers
46
type Metrics struct {
47
// Enable per-host metrics. Enabling this option may
48
// incur high-memory consumption, depending on the number of hosts
49
// managed by Caddy.
50
+ //
51
+ // CARDINALITY PROTECTION: To prevent unbounded cardinality attacks,
52
+ // only explicitly configured hosts (via host matchers) are allowed
53
+ // by default. Other hosts are aggregated under the "_other" label.
54
+ // See AllowCatchAllHosts to change this behavior.
55
PerHost bool `json:"per_host,omitempty"`
56
26
- init sync.Once
27
- httpMetrics *httpMetrics `json:"-"`
57
+ // Allow metrics for catch-all hosts (hosts without explicit configuration).
58
+ // When false (default), only hosts explicitly configured via host matchers
59
+ // will get individual metrics labels. All other hosts will be aggregated
60
+ // under the "_other" label to prevent cardinality explosion.
61
+ //
62
+ // This is automatically enabled for HTTPS servers (since certificates provide
63
+ // some protection against unbounded cardinality), but disabled for HTTP servers
64
+ // by default to prevent cardinality attacks from arbitrary Host headers.
65
+ //
66
+ // Set to true to allow all hosts to get individual metrics (NOT RECOMMENDED
67
+ // for production environments exposed to the internet).
68
+ AllowCatchAllHosts bool `json:"allow_catch_all_hosts,omitempty"`
69
+
70
+ init sync.Once
71
+ httpMetrics *httpMetrics
72
+ allowedHosts map[string]struct{}
73
+ hasHTTPSServer bool
74
}
75
76
type httpMetrics struct {
@@ -101,6 +147,63 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) {
147
}, httpLabels)
148
}
149
150
+// scanConfigForHosts scans the HTTP app configuration to build a set of allowed hosts
151
+// for metrics collection, similar to how auto-HTTPS scans for domain names.
152
+func (m *Metrics) scanConfigForHosts(app *App) {
153
+ if !m.PerHost {
154
+ return
155
+ }
156
+
157
+ m.allowedHosts = make(map[string]struct{})
158
+ m.hasHTTPSServer = false
159
+
160
+ for _, srv := range app.Servers {
161
+ // Check if this server has TLS enabled
162
+ serverHasTLS := len(srv.TLSConnPolicies) > 0
163
+ if serverHasTLS {
164
+ m.hasHTTPSServer = true
165
+ }
166
+
167
+ // Collect hosts from route matchers
168
+ for _, route := range srv.Routes {
169
+ for _, matcherSet := range route.MatcherSets {
170
+ for _, matcher := range matcherSet {
171
+ if hm, ok := matcher.(*MatchHost); ok {
172
+ for _, host := range *hm {
173
+ // Only allow non-fuzzy hosts to prevent unbounded cardinality
174
+ if !hm.fuzzy(host) {
175
+ m.allowedHosts[strings.ToLower(host)] = struct{}{}
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+}
184
+
185
+// shouldAllowHostMetrics determines if metrics should be collected for the given host.
186
+// This implements the cardinality protection by only allowing metrics for:
187
+// 1. Explicitly configured hosts
188
+// 2. Catch-all requests on HTTPS servers (if AllowCatchAllHosts is true or auto-enabled)
189
+// 3. Catch-all requests on HTTP servers only if explicitly allowed
190
+func (m *Metrics) shouldAllowHostMetrics(host string, isHTTPS bool) bool {
191
+ if !m.PerHost {
192
+ return true // host won't be used in labels anyway
193
+ }
194
+
195
+ normalizedHost := strings.ToLower(host)
196
+
197
+ // Always allow explicitly configured hosts
198
+ if _, exists := m.allowedHosts[normalizedHost]; exists {
199
+ return true
200
+ }
201
+
202
+ // For catch-all requests (not in allowed hosts)
203
+ allowCatchAll := m.AllowCatchAllHosts || (isHTTPS && m.hasHTTPSServer)
204
+ return allowCatchAll
205
+}
206
+
207
// serverNameFromContext extracts the current server name from the context.
208
// Returns "UNKNOWN" if none is available (should probably never happen).
209
func serverNameFromContext(ctx context.Context) string {
@@ -133,9 +236,19 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
236
// of a panic
237
statusLabels := prometheus.Labels{"server": server, "handler": h.handler, "method": method, "code": ""}
238
239
+ // Determine if this is an HTTPS request
240
+ isHTTPS := r.TLS != nil
241
+
242
if h.metrics.PerHost {
137
- labels["host"] = strings.ToLower(r.Host)
138
- statusLabels["host"] = strings.ToLower(r.Host)
243
+ // Apply cardinality protection for host metrics
244
+ if h.metrics.shouldAllowHostMetrics(r.Host, isHTTPS) {
245
+ labels["host"] = strings.ToLower(r.Host)
246
+ statusLabels["host"] = strings.ToLower(r.Host)
247
+ } else {
248
+ // Use a catch-all label for unallowed hosts to prevent cardinality explosion
249
+ labels["host"] = "_other"
250
+ statusLabels["host"] = "_other"
251
+ }
252
}
253
254
inFlight := h.metrics.httpMetrics.requestInFlight.With(labels)
modules/caddyhttp/metrics_test.go
+112
-3
@@ -2,6 +2,7 @@ package caddyhttp
2
3
import (
4
"context"
5
+ "crypto/tls"
6
"errors"
7
"net/http"
8
"net/http/httptest"
@@ -206,9 +207,11 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
207
func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
208
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
209
metrics := &Metrics{
209
- PerHost: true,
210
- init: sync.Once{},
211
- httpMetrics: &httpMetrics{},
210
+ PerHost: true,
211
+ AllowCatchAllHosts: true, // Allow all hosts for testing
212
+ init: sync.Once{},
213
+ httpMetrics: &httpMetrics{},
214
+ allowedHosts: make(map[string]struct{}),
215
}
216
handlerErr := errors.New("oh noes")
217
response := []byte("hello world!")
@@ -379,6 +382,112 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
382
}
383
}
384
385
+func TestMetricsCardinalityProtection(t *testing.T) {
386
+ ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
387
+
388
+ // Test 1: Without AllowCatchAllHosts, arbitrary hosts should be mapped to "_other"
389
+ metrics := &Metrics{
390
+ PerHost: true,
391
+ AllowCatchAllHosts: false, // Default - should map unknown hosts to "_other"
392
+ init: sync.Once{},
393
+ httpMetrics: &httpMetrics{},
394
+ allowedHosts: make(map[string]struct{}),
395
+ }
396
+
397
+ // Add one allowed host
398
+ metrics.allowedHosts["allowed.com"] = struct{}{}
399
+
400
+ mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
401
+ w.Write([]byte("hello"))
402
+ return nil
403
+ })
404
+
405
+ ih := newMetricsInstrumentedHandler(ctx, "test", mh, metrics)
406
+
407
+ // Test request to allowed host
408
+ r1 := httptest.NewRequest("GET", "http://allowed.com/", nil)
409
+ r1.Host = "allowed.com"
410
+ w1 := httptest.NewRecorder()
411
+ ih.ServeHTTP(w1, r1, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
412
+
413
+ // Test request to unknown host (should be mapped to "_other")
414
+ r2 := httptest.NewRequest("GET", "http://attacker.com/", nil)
415
+ r2.Host = "attacker.com"
416
+ w2 := httptest.NewRecorder()
417
+ ih.ServeHTTP(w2, r2, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
418
+
419
+ // Test request to another unknown host (should also be mapped to "_other")
420
+ r3 := httptest.NewRequest("GET", "http://evil.com/", nil)
421
+ r3.Host = "evil.com"
422
+ w3 := httptest.NewRecorder()
423
+ ih.ServeHTTP(w3, r3, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
424
+
425
+ // Check that metrics contain:
426
+ // - One entry for "allowed.com"
427
+ // - One entry for "_other" (aggregating attacker.com and evil.com)
428
+ expected := `
429
+ # HELP caddy_http_requests_total Counter of HTTP(S) requests made.
430
+ # TYPE caddy_http_requests_total counter
431
+ caddy_http_requests_total{handler="test",host="_other",server="UNKNOWN"} 2
432
+ caddy_http_requests_total{handler="test",host="allowed.com",server="UNKNOWN"} 1
433
+ `
434
+
435
+ if err := testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expected),
436
+ "caddy_http_requests_total",
437
+ ); err != nil {
438
+ t.Errorf("Cardinality protection test failed: %s", err)
439
+ }
440
+}
441
+
442
+func TestMetricsHTTPSCatchAll(t *testing.T) {
443
+ ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
444
+
445
+ // Test that HTTPS requests allow catch-all even when AllowCatchAllHosts is false
446
+ metrics := &Metrics{
447
+ PerHost: true,
448
+ AllowCatchAllHosts: false,
449
+ hasHTTPSServer: true, // Simulate having HTTPS servers
450
+ init: sync.Once{},
451
+ httpMetrics: &httpMetrics{},
452
+ allowedHosts: make(map[string]struct{}), // Empty - no explicitly allowed hosts
453
+ }
454
+
455
+ mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error {
456
+ w.Write([]byte("hello"))
457
+ return nil
458
+ })
459
+
460
+ ih := newMetricsInstrumentedHandler(ctx, "test", mh, metrics)
461
+
462
+ // Test HTTPS request (should be allowed even though not in allowedHosts)
463
+ r1 := httptest.NewRequest("GET", "https://unknown.com/", nil)
464
+ r1.Host = "unknown.com"
465
+ r1.TLS = &tls.ConnectionState{} // Mark as TLS/HTTPS
466
+ w1 := httptest.NewRecorder()
467
+ ih.ServeHTTP(w1, r1, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
468
+
469
+ // Test HTTP request (should be mapped to "_other")
470
+ r2 := httptest.NewRequest("GET", "http://unknown.com/", nil)
471
+ r2.Host = "unknown.com"
472
+ // No TLS field = HTTP request
473
+ w2 := httptest.NewRecorder()
474
+ ih.ServeHTTP(w2, r2, HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }))
475
+
476
+ // Check that HTTPS request gets real host, HTTP gets "_other"
477
+ expected := `
478
+ # HELP caddy_http_requests_total Counter of HTTP(S) requests made.
479
+ # TYPE caddy_http_requests_total counter
480
+ caddy_http_requests_total{handler="test",host="_other",server="UNKNOWN"} 1
481
+ caddy_http_requests_total{handler="test",host="unknown.com",server="UNKNOWN"} 1
482
+ `
483
+
484
+ if err := testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expected),
485
+ "caddy_http_requests_total",
486
+ ); err != nil {
487
+ t.Errorf("HTTPS catch-all test failed: %s", err)
488
+ }
489
+}
490
+
491
type middlewareHandlerFunc func(http.ResponseWriter, *http.Request, Handler) error
492
493
func (f middlewareHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, h Handler) error {