From 681c2cc2ed6b7c37bc2ba37ce475b505252c56a8 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 23 Apr 2026 00:17:09 -0400 Subject: [PATCH] feat(metrics): basic auth support (#1579) * feat(internal): add basic auth HTTP middleware Signed-off-by: Xe Iaso * feat(config): add HTTP basic auth for metrics Signed-off-by: Xe Iaso * feat(metrics): wire up basic auth Signed-off-by: Xe Iaso * doc: document HTTP basic auth for metrics server Signed-off-by: Xe Iaso * chore: spelling Signed-off-by: Xe Iaso * docs(admin/policies): give people a python command Signed-off-by: Xe Iaso --------- Signed-off-by: Xe Iaso --- .github/actions/spelling/allow.txt | 1 + data/botPolicies.yaml | 7 ++ docs/docs/CHANGELOG.md | 1 + docs/docs/admin/policies.mdx | 18 ++++ internal/basicauth.go | 52 +++++++++++ internal/basicauth_test.go | 138 +++++++++++++++++++++++++++++ lib/config/metrics.go | 65 ++++++++++---- lib/config/metrics_test.go | 73 +++++++++++++++ lib/metrics/metrics.go | 7 ++ 9 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 internal/basicauth.go create mode 100644 internal/basicauth_test.go diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index e0dc44d7..4bbdfe98 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -38,3 +38,4 @@ Samsung wenet qwertiko setuplistener +mba diff --git a/data/botPolicies.yaml b/data/botPolicies.yaml index fd1d1a34..d6d3671e 100644 --- a/data/botPolicies.yaml +++ b/data/botPolicies.yaml @@ -175,6 +175,13 @@ status_codes: # bind: ":9090" # network: "tcp" # +# # To protect your metrics server with basic auth, set credentials below: +# # +# # https://anubis.techaro.lol/docs/admin/policies#http-basic-authentication +# basicAuth: +# username: "" +# password: "" +# # # To serve metrics over TLS, set the path to the right TLS certificate and key # # here. When the files change on disk, they will automatically be reloaded. # # diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index d1bd4935..f4826178 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394)) - Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)). - Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls). +- Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server. ## v1.25.0: Necron diff --git a/docs/docs/admin/policies.mdx b/docs/docs/admin/policies.mdx index 06de8dd8..a7d4e8b7 100644 --- a/docs/docs/admin/policies.mdx +++ b/docs/docs/admin/policies.mdx @@ -171,6 +171,24 @@ metrics: As it is not expected for certificate authority certificates to change often, the CA certificate will NOT be automatically reloaded when the respective file changes. +### HTTP basic authentication + +Anubis' metrics server also supports setting HTTP basic auth as a lightweight protection against unauthorized users viewing metrics data. As the basic auth credentials are hardcoded in the configuration file, administrators SHOULD use randomly generated credentials, such as type-4 UUIDs or other high entropy strings. These credentials MUST NOT be sensitive or used to protect other high value systems. + +Configure it with the `basicAuth` block under `metrics`: + +```yaml +metrics: + bind: ":9090" + network: "tcp" + + basicAuth: + username: azurediamond + password: hunter2 +``` + +If you have Python installed, you can generate a high entropy password with `python -c 'import secrets; print(secrets.token_urlsafe(32))'`. + ## Imprint / Impressum support Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information. diff --git a/internal/basicauth.go b/internal/basicauth.go new file mode 100644 index 00000000..69725ab0 --- /dev/null +++ b/internal/basicauth.go @@ -0,0 +1,52 @@ +package internal + +import ( + "crypto/sha256" + "crypto/subtle" + "fmt" + "log/slog" + "net/http" +) + +// BasicAuth wraps next in HTTP Basic authentication using the provided +// credentials. If either username or password is empty, next is returned +// unchanged and a debug log line is emitted. +// +// Credentials are compared in constant time to avoid leaking information +// through timing side channels. +func BasicAuth(realm, username, password string, next http.Handler) http.Handler { + if username == "" || password == "" { + slog.Debug("skipping middleware, basic auth credentials are empty") + return next + } + + expectedUser := sha256.Sum256([]byte(username)) + expectedPass := sha256.Sum256([]byte(password)) + challenge := fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", realm) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + unauthorized(w, challenge) + return + } + + gotUser := sha256.Sum256([]byte(user)) + gotPass := sha256.Sum256([]byte(pass)) + + userMatch := subtle.ConstantTimeCompare(gotUser[:], expectedUser[:]) + passMatch := subtle.ConstantTimeCompare(gotPass[:], expectedPass[:]) + + if userMatch&passMatch != 1 { + unauthorized(w, challenge) + return + } + + next.ServeHTTP(w, r) + }) +} + +func unauthorized(w http.ResponseWriter, challenge string) { + w.Header().Set("WWW-Authenticate", challenge) + http.Error(w, "Unauthorized", http.StatusUnauthorized) +} \ No newline at end of file diff --git a/internal/basicauth_test.go b/internal/basicauth_test.go new file mode 100644 index 00000000..8492d226 --- /dev/null +++ b/internal/basicauth_test.go @@ -0,0 +1,138 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) +} + +func TestBasicAuth(t *testing.T) { + t.Parallel() + + const ( + realm = "test-realm" + username = "admin" + password = "hunter2" + ) + + for _, tt := range []struct { + name string + setAuth bool + user string + pass string + wantStatus int + wantBody string + wantChall bool + }{ + { + name: "valid credentials", + setAuth: true, + user: username, + pass: password, + wantStatus: http.StatusOK, + wantBody: "ok", + }, + { + name: "missing credentials", + setAuth: false, + wantStatus: http.StatusUnauthorized, + wantChall: true, + }, + { + name: "wrong username", + setAuth: true, + user: "nobody", + pass: password, + wantStatus: http.StatusUnauthorized, + wantChall: true, + }, + { + name: "wrong password", + setAuth: true, + user: username, + pass: "wrong", + wantStatus: http.StatusUnauthorized, + wantChall: true, + }, + { + name: "empty supplied credentials", + setAuth: true, + user: "", + pass: "", + wantStatus: http.StatusUnauthorized, + wantChall: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := BasicAuth(realm, username, password, okHandler()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tt.setAuth { + req.SetBasicAuth(tt.user, tt.pass) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if tt.wantBody != "" && rec.Body.String() != tt.wantBody { + t.Errorf("body = %q, want %q", rec.Body.String(), tt.wantBody) + } + + chall := rec.Header().Get("WWW-Authenticate") + if tt.wantChall { + if chall == "" { + t.Error("WWW-Authenticate header missing on 401") + } + if !strings.Contains(chall, realm) { + t.Errorf("WWW-Authenticate = %q, want realm %q", chall, realm) + } + } else if chall != "" { + t.Errorf("unexpected WWW-Authenticate header: %q", chall) + } + }) + } +} + +func TestBasicAuthPassthrough(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + username string + password string + }{ + {name: "empty username", username: "", password: "hunter2"}, + {name: "empty password", username: "admin", password: ""}, + {name: "both empty", username: "", password: ""}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := BasicAuth("realm", tt.username, tt.password, okHandler()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d (passthrough expected)", rec.Code, http.StatusOK) + } + if rec.Body.String() != "ok" { + t.Errorf("body = %q, want %q", rec.Body.String(), "ok") + } + }) + } +} \ No newline at end of file diff --git a/lib/config/metrics.go b/lib/config/metrics.go index a4fcc7fb..27316808 100644 --- a/lib/config/metrics.go +++ b/lib/config/metrics.go @@ -10,25 +10,29 @@ import ( ) var ( - ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration") - ErrInvalidMetricsTLSConfig = errors.New("config: invalid metrics TLS configuration") - ErrNoMetricsBind = errors.New("config.Metrics: must define bind") - ErrNoMetricsNetwork = errors.New("config.Metrics: must define network") - ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets") - ErrInvalidMetricsSocketMode = errors.New("config.Metrics: invalid unix socket mode") - ErrInvalidMetricsNetwork = errors.New("config.Metrics: invalid metrics network") - ErrNoMetricsTLSCertificate = errors.New("config.Metrics.TLS: must define certificate file") - ErrNoMetricsTLSKey = errors.New("config.Metrics.TLS: must define key file") - ErrInvalidMetricsTLSKeypair = errors.New("config.Metrics.TLS: keypair is invalid") - ErrInvalidMetricsCACertificate = errors.New("config.Metrics.TLS: invalid CA certificate") - ErrCantReadFile = errors.New("config: can't read required file") + ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration") + ErrInvalidMetricsTLSConfig = errors.New("config: invalid metrics TLS configuration") + ErrInvalidMetricsBasicAuthConfig = errors.New("config: invalid metrics basic auth configuration") + ErrNoMetricsBind = errors.New("config.Metrics: must define bind") + ErrNoMetricsNetwork = errors.New("config.Metrics: must define network") + ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets") + ErrInvalidMetricsSocketMode = errors.New("config.Metrics: invalid unix socket mode") + ErrInvalidMetricsNetwork = errors.New("config.Metrics: invalid metrics network") + ErrNoMetricsTLSCertificate = errors.New("config.Metrics.TLS: must define certificate file") + ErrNoMetricsTLSKey = errors.New("config.Metrics.TLS: must define key file") + ErrInvalidMetricsTLSKeypair = errors.New("config.Metrics.TLS: keypair is invalid") + ErrInvalidMetricsCACertificate = errors.New("config.Metrics.TLS: invalid CA certificate") + ErrCantReadFile = errors.New("config: can't read required file") + ErrNoMetricsBasicAuthUsername = errors.New("config.Metrics.BasicAuth: must define username") + ErrNoMetricsBasicAuthPassword = errors.New("config.Metrics.BasicAuth: must define password") ) type Metrics struct { - Bind string `json:"bind" yaml:"bind"` - Network string `json:"network" yaml:"network"` - SocketMode string `json:"socketMode" yaml:"socketMode"` - TLS *MetricsTLS `json:"tls" yaml:"tls"` + Bind string `json:"bind" yaml:"bind"` + Network string `json:"network" yaml:"network"` + SocketMode string `json:"socketMode" yaml:"socketMode"` + TLS *MetricsTLS `json:"tls" yaml:"tls"` + BasicAuth *MetricsBasicAuth `json:"basicAuth" yaml:"basicAuth"` } func (m *Metrics) Valid() error { @@ -62,6 +66,12 @@ func (m *Metrics) Valid() error { } } + if m.BasicAuth != nil { + if err := m.BasicAuth.Valid(); err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { return errors.Join(ErrInvalidMetricsConfig, errors.Join(errs...)) } @@ -131,3 +141,26 @@ func canReadFile(fname string) error { return nil } + +type MetricsBasicAuth struct { + Username string `json:"username" yaml:"username"` + Password string `json:"password" yaml:"password"` +} + +func (mba *MetricsBasicAuth) Valid() error { + var errs []error + + if mba.Username == "" { + errs = append(errs, ErrNoMetricsBasicAuthUsername) + } + + if mba.Password == "" { + errs = append(errs, ErrNoMetricsBasicAuthPassword) + } + + if len(errs) != 0 { + return errors.Join(ErrInvalidMetricsBasicAuthConfig, errors.Join(errs...)) + } + + return nil +} diff --git a/lib/config/metrics_test.go b/lib/config/metrics_test.go index 2e3335f5..c4876caa 100644 --- a/lib/config/metrics_test.go +++ b/lib/config/metrics_test.go @@ -157,6 +157,79 @@ func TestMetricsValid(t *testing.T) { }, err: ErrInvalidMetricsCACertificate, }, + { + name: "basic auth credentials set", + input: &Metrics{ + Bind: ":9090", + Network: "tcp", + BasicAuth: &MetricsBasicAuth{ + Username: "admin", + Password: "hunter2", + }, + }, + }, + { + name: "invalid basic auth config", + input: &Metrics{ + Bind: ":9090", + Network: "tcp", + BasicAuth: &MetricsBasicAuth{}, + }, + err: ErrInvalidMetricsBasicAuthConfig, + }, + } { + t.Run(tt.name, func(t *testing.T) { + if err := tt.input.Valid(); !errors.Is(err, tt.err) { + t.Logf("wanted error: %v", tt.err) + t.Logf("got error: %v", err) + t.Error("validation failed") + } + }) + } +} + +func TestMetricsBasicAuthValid(t *testing.T) { + for _, tt := range []struct { + name string + input *MetricsBasicAuth + err error + }{ + { + name: "both set", + input: &MetricsBasicAuth{ + Username: "admin", + Password: "hunter2", + }, + }, + { + name: "empty username and password", + input: &MetricsBasicAuth{}, + err: ErrInvalidMetricsBasicAuthConfig, + }, + { + name: "missing username", + input: &MetricsBasicAuth{ + Password: "hunter2", + }, + err: ErrNoMetricsBasicAuthUsername, + }, + { + name: "missing password", + input: &MetricsBasicAuth{ + Username: "admin", + }, + err: ErrNoMetricsBasicAuthPassword, + }, + { + name: "missing both surfaces wrapper error", + input: &MetricsBasicAuth{}, + err: ErrNoMetricsBasicAuthUsername, + }, + { + name: "missing both surfaces password error", + input: &MetricsBasicAuth{}, + err: ErrNoMetricsBasicAuthPassword, + }, } { t.Run(tt.name, func(t *testing.T) { if err := tt.input.Valid(); !errors.Is(err, tt.err) { diff --git a/lib/metrics/metrics.go b/lib/metrics/metrics.go index d94c089d..ff381fc8 100644 --- a/lib/metrics/metrics.go +++ b/lib/metrics/metrics.go @@ -97,6 +97,13 @@ func (s *Server) run(ctx context.Context, lg *slog.Logger) error { } } + if s.Config.BasicAuth != nil { + var h http.Handler = mux + h = internal.BasicAuth("anubis-metrics", s.Config.BasicAuth.Username, s.Config.BasicAuth.Password, mux) + + srv.Handler = h + } + lg.Debug("listening for metrics", "url", metricsURL) go func() {