From 48d85038309f757e21159662752481e1d8a1ba2c Mon Sep 17 00:00:00 2001 From: Michael Gorbovitski Date: Wed, 17 Jun 2026 15:00:34 -0400 Subject: [PATCH] auth: /metrics public by default, gated by metrics_require_auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Prometheus scrape endpoint is operational telemetry and scrapers can't carry admin auth, so /metrics bypasses the auth middleware by default (like /health). Because the metric labels can include provider/model/virtual-key/team/customer/cost, operators who consider that sensitive can set client config metrics_require_auth=true to keep /metrics behind auth. Implemented as an exact-match gate in APIMiddleware reading an atomic flag (default false), fed from ClientConfig.MetricsRequireAuth via UpdateMetricsRequireAuth — not a hardcoded unconditional whitelist. Also drops a duplicate /health entry. Tests cover public-by-default, gated-when-set, and the exact-match guard (/metricsX and /metrics/foo stay authenticated). Co-Authored-By: Claude Opus 4.6 --- framework/configstore/clientconfig.go | 1 + .../bifrost-http/handlers/middlewares.go | 17 +++++++- .../bifrost-http/handlers/middlewares_test.go | 41 +++++++++++++++++++ transports/bifrost-http/server/server.go | 1 + transports/config.schema.json | 5 +++ 5 files changed, 64 insertions(+), 1 deletion(-) diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index d2b5f8806f..06a28ee5c8 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -95,6 +95,7 @@ type ClientConfig struct { RequiredHeaders []string `json:"required_headers,omitempty"` // Headers that must be present on every request (case-insensitive) LoggingHeaders []string `json:"logging_headers,omitempty"` // Headers to capture in log metadata WhitelistedRoutes []string `json:"whitelisted_routes,omitempty"` // Routes that bypass auth middleware + MetricsRequireAuth bool `json:"metrics_require_auth"` // Require auth on the Prometheus /metrics endpoint (default false: /metrics is public, like /health — scrapers can't carry admin auth). Set true to keep /metrics behind the auth middleware. HideDeletedVirtualKeysInFilters bool `json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys from logs/MCP filter data RoutingChainMaxDepth int `json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) MCPExternalClientURL *schemas.EnvVar `json:"mcp_external_client_url,omitempty"` // Public base URL used as redirect_uri when Bifrost acts as an OAuth client to upstream MCP servers. Supports env var syntax ("env.MY_VAR") diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go index dc0e4b4e08..68ac8f4ad4 100644 --- a/transports/bifrost-http/handlers/middlewares.go +++ b/transports/bifrost-http/handlers/middlewares.go @@ -729,6 +729,9 @@ type AuthMiddleware struct { wsTicketStore *WSTicketStore tempTokensService *temptoken.Service // optional; when nil, temp-token fallback is disabled tempTokensEnabled atomic.Bool + // metricsRequireAuth, when true, keeps /metrics behind the auth middleware. Default false: + // /metrics is public (Prometheus scrapers can't carry admin auth), like /health. + metricsRequireAuth atomic.Bool } // InitAuthMiddleware initializes the auth middleware. The tempTokens service @@ -756,10 +759,12 @@ func InitAuthMiddleware(store configstore.ConfigStore, wsTicketStore *WSTicketSt if err == nil && clientConfig != nil { am.whitelistedRoutes.Store(&clientConfig.WhitelistedRoutes) am.tempTokensEnabled.Store(clientConfig.MCPEnableTempTokenAuth) + am.metricsRequireAuth.Store(clientConfig.MetricsRequireAuth) } else { emptyRoutes := []string{} am.whitelistedRoutes.Store(&emptyRoutes) am.tempTokensEnabled.Store(false) + am.metricsRequireAuth.Store(false) } return am, nil @@ -774,6 +779,11 @@ func (m *AuthMiddleware) UpdateWhitelistedRoutes(routes []string) { m.whitelistedRoutes.Store(&routes) } +// UpdateMetricsRequireAuth updates whether the /metrics endpoint requires auth. +func (m *AuthMiddleware) UpdateMetricsRequireAuth(requireAuth bool) { + m.metricsRequireAuth.Store(requireAuth) +} + // UpdateTempTokenAuthEnabled updates whether scoped temp-token fallback auth is accepted. func (m *AuthMiddleware) UpdateTempTokenAuthEnabled(enabled bool) { m.tempTokensEnabled.Store(enabled) @@ -846,7 +856,6 @@ func (m *AuthMiddleware) APIMiddleware() schemas.BifrostHTTPMiddleware { "/api/scim/oauth/callback", "/api/scim/oauth/refresh", "/api/scim/oauth/logout", - "/health", "/api/version", } whitelistedPrefixes := []string{ @@ -864,6 +873,12 @@ func (m *AuthMiddleware) APIMiddleware() schemas.BifrostHTTPMiddleware { "/api/skills/serve/", } return m.middleware(func(authConfig *configstore.AuthConfig, url string) bool { + // The Prometheus scrape endpoint is operational telemetry; Prometheus scrapers can't carry + // admin auth, so /metrics is public by default (like /health). Operators who consider the + // metric labels sensitive can set client config metrics_require_auth=true to gate it. + if url == "/metrics" { + return !m.metricsRequireAuth.Load() + } if slices.Contains(systemWhitelistedRoutes, url) || slices.IndexFunc(whitelistedPrefixes, func(prefix string) bool { return strings.HasPrefix(url, prefix) diff --git a/transports/bifrost-http/handlers/middlewares_test.go b/transports/bifrost-http/handlers/middlewares_test.go index 1084ac56e9..ba5fd6013c 100644 --- a/transports/bifrost-http/handlers/middlewares_test.go +++ b/transports/bifrost-http/handlers/middlewares_test.go @@ -750,6 +750,7 @@ func TestAuthMiddleware_WhitelistedRoutes(t *testing.T) { "/api/session/login", "/api/oauth/callback", "/health", + "/metrics", } for _, route := range whitelistedRoutes { @@ -773,6 +774,46 @@ func TestAuthMiddleware_WhitelistedRoutes(t *testing.T) { } } +// TestAuthMiddleware_Metrics covers the /metrics auth policy: public by default, gated when +// metrics_require_auth is set, and the exact-match guard (a near-miss path is NOT exempted). +func TestAuthMiddleware_Metrics(t *testing.T) { + SetLogger(&mockLogger{}) + + newAM := func(requireAuth bool) *AuthMiddleware { + am := &AuthMiddleware{} + am.UpdateAuthConfig(&configstore.AuthConfig{ + AdminUserName: schemas.NewEnvVar("admin"), + AdminPassword: schemas.NewEnvVar("hashedpassword"), + IsEnabled: true, + }) + am.UpdateMetricsRequireAuth(requireAuth) + return am + } + + passes := func(am *AuthMiddleware, url string) bool { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI(url) + nextCalled := false + am.APIMiddleware()(func(ctx *fasthttp.RequestCtx) { nextCalled = true })(ctx) + return nextCalled + } + + // Default (metrics_require_auth=false): /metrics is public even with auth enabled. + if !passes(newAM(false), "/metrics") { + t.Error("/metrics should bypass auth by default") + } + // metrics_require_auth=true: /metrics is gated by auth. + if passes(newAM(true), "/metrics") { + t.Error("/metrics should require auth when metrics_require_auth=true") + } + // Exact-match guard: a near-miss path must NOT be treated as the public /metrics route. + for _, u := range []string{"/metricsX", "/metrics/foo"} { + if passes(newAM(false), u) { + t.Errorf("%s must not be whitelisted as /metrics", u) + } + } +} + func TestAuthMiddleware_InferenceMiddleware_RealtimeTransportBypassesAuth(t *testing.T) { SetLogger(&mockLogger{}) diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 223b9fddec..df5ab2a737 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -835,6 +835,7 @@ func (s *BifrostHTTPServer) ReloadClientConfigFromConfigStore(ctx context.Contex if s.AuthMiddleware != nil { s.AuthMiddleware.UpdateWhitelistedRoutes(config.WhitelistedRoutes) s.AuthMiddleware.UpdateTempTokenAuthEnabled(config.MCPEnableTempTokenAuth) + s.AuthMiddleware.UpdateMetricsRequireAuth(config.MetricsRequireAuth) } // Reloading config in bifrost client if s.Client != nil { diff --git a/transports/config.schema.json b/transports/config.schema.json index c3262290e9..c188de755e 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -200,6 +200,11 @@ }, "description": "Routes that bypass auth middleware. Requests to these exact paths skip authentication checks." }, + "metrics_require_auth": { + "type": "boolean", + "description": "When true, the Prometheus /metrics endpoint requires authentication. Default false: /metrics is public (like /health), since scrapers typically cannot carry admin credentials. Set true if the metric labels (provider/model/virtual-key/team/customer/cost) are sensitive in your deployment.", + "default": false + }, "hide_deleted_virtual_keys_in_filters": { "type": "boolean", "description": "When true, deleted virtual keys are omitted from logs and MCP logs filter data.",