@marcel91200 / caddy Mirror · read-only / commits / 595aab8b
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, 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 {