Skip to content

Commit 595aab8

Browse files
authored
metrics: resolve per-host inifinite cardinality (#7306)
1 parent 8aca108 commit 595aab8

File tree

3 files changed

+231
-7
lines changed

3 files changed

+231
-7
lines changed

modules/caddyhttp/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ func (app *App) Provision(ctx caddy.Context) error {
198198
if app.Metrics != nil {
199199
app.Metrics.init = sync.Once{}
200200
app.Metrics.httpMetrics = &httpMetrics{}
201+
// Scan config for allowed hosts to prevent cardinality explosion
202+
app.Metrics.scanConfigForHosts(app)
201203
}
202204
// prepare each server
203205
oldContext := ctx.Context

modules/caddyhttp/metrics.go

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,60 @@ import (
1717

1818
// Metrics configures metrics observations.
1919
// 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
2046
type Metrics struct {
2147
// Enable per-host metrics. Enabling this option may
2248
// incur high-memory consumption, depending on the number of hosts
2349
// 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.
2455
PerHost bool `json:"per_host,omitempty"`
2556

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
2874
}
2975

3076
type httpMetrics struct {
@@ -101,6 +147,63 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) {
101147
}, httpLabels)
102148
}
103149

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+
104207
// serverNameFromContext extracts the current server name from the context.
105208
// Returns "UNKNOWN" if none is available (should probably never happen).
106209
func serverNameFromContext(ctx context.Context) string {
@@ -133,9 +236,19 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
133236
// of a panic
134237
statusLabels := prometheus.Labels{"server": server, "handler": h.handler, "method": method, "code": ""}
135238

239+
// Determine if this is an HTTPS request
240+
isHTTPS := r.TLS != nil
241+
136242
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+
}
139252
}
140253

141254
inFlight := h.metrics.httpMetrics.requestInFlight.With(labels)

modules/caddyhttp/metrics_test.go

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package caddyhttp
22

33
import (
44
"context"
5+
"crypto/tls"
56
"errors"
67
"net/http"
78
"net/http/httptest"
@@ -206,9 +207,11 @@ func TestMetricsInstrumentedHandler(t *testing.T) {
206207
func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
207208
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
208209
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{}),
212215
}
213216
handlerErr := errors.New("oh noes")
214217
response := []byte("hello world!")
@@ -379,6 +382,112 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) {
379382
}
380383
}
381384

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+
382491
type middlewareHandlerFunc func(http.ResponseWriter, *http.Request, Handler) error
383492

384493
func (f middlewareHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, h Handler) error {

0 commit comments

Comments
 (0)