From 7cdc5b541ca6258d51c1db9b897de678da59174e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Wed, 2 Sep 2026 14:24:44 +0200 Subject: [PATCH] web: generate the basic auth decoy hash at the cost in use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a request names a user that is not configured, the handler compares the supplied password against a fixed hash so that the request takes about as long as one naming a configured user. The comment says this is there "to prevent user enumeration by timing requests". That hash was hardcoded at bcrypt cost 10, while the cost of the real users' hashes is whatever the operator chose. Whenever they chose a higher one, the mitigation does not hold: a wrong password for a configured user runs a comparison at the configured cost, and a wrong password for an unknown user runs one at cost 10. Measured with users hashed at cost 14, a rejected request for a configured user took 1.12s and one for an unknown user took 70ms, which is a signal that is trivial to read over a network. Generate the decoy at the highest cost among the configured users instead, so the two paths cost the same. The highest is used rather than an average because a decoy cheaper than some configured user leaves that user enumerable. Where the configured costs differ the cheaper users are still answered faster than the decoy, so using one cost for every user remains the right thing to do; that is now called out in the documentation. Hashes are generated at most once per cost and cached for the lifetime of the process, since generating one is as expensive as comparing against it. This makes rejected requests as expensive as the most expensive configured user, which is the point: the two cases have to cost the same. It is worth being explicit that this raises the cost of an unauthenticated request from a fixed ~70ms to whatever the highest configured cost is - measured here, 286ms at cost 12, 1.1s at cost 14 and 18s at cost 18. A wrong password for a known username already cost that much before this change, so the exposure is not new, but it no longer requires knowing a username. The documentation now says so and points at rate_limit. The two shared user fixtures carried a cost 18 entry for a user that no test ever authenticates as. With the decoy tracking the highest cost, that entry alone added roughly 70s to the suite. Lower it to cost 14, which keeps the fixtures covering a spread of costs at a fraction of the runtime. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Manuel RĂ¼ger --- docs/web-configuration.md | 10 +- web/decoy_test.go | 111 +++++++++++++++++++ web/handler.go | 76 ++++++++++++- web/testdata/web_config_users.good.yml | 2 +- web/testdata/web_config_users_noTLS.good.yml | 2 +- 5 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 web/decoy_test.go diff --git a/docs/web-configuration.md b/docs/web-configuration.md index 7e996c70..48663ffe 100644 --- a/docs/web-configuration.md +++ b/docs/web-configuration.md @@ -153,9 +153,17 @@ which will look something like: The cost (10 in the example) influences the time it takes for computing the hash. A higher cost will end up slowing down the authentication process. Depending on the machine, a cost of 10 will take about ~70ms, whereas a cost of -18 can take up to a few seconds. That hash will be computed on the first +18 can take tens of seconds. That hash will be computed on the first authenticated HTTP request and then cached. +The cost also bounds how expensive an unauthenticated request can be. A request +naming a user that is not configured is compared against a decoy hash generated +at the highest cost in use, so that it cannot be told apart from a request for a +configured user by how long it takes. This means a single expensive user makes +every rejected request that expensive too. Keep the cost the same for all users, +pick it with that in mind, and consider configuring `rate_limit` alongside +`basic_auth_users`. + ## Performance Basic authentication is meant for simple use cases, with a few users. If you diff --git a/web/decoy_test.go b/web/decoy_test.go new file mode 100644 index 00000000..33641447 --- /dev/null +++ b/web/decoy_test.go @@ -0,0 +1,111 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "testing" + + config_util "github.com/prometheus/common/config" + "golang.org/x/crypto/bcrypt" +) + +func hashAtCost(t *testing.T, cost int) config_util.Secret { + t.Helper() + h, err := bcrypt.GenerateFromPassword([]byte("password"), cost) + if err != nil { + t.Fatalf("GenerateFromPassword: %v", err) + } + return config_util.Secret(h) +} + +// TestMaxBcryptCost checks that the decoy cost tracks the most expensive +// configured user, so that a request for an unknown user is never answered +// faster than one for any configured user. +func TestMaxBcryptCost(t *testing.T) { + cheap := hashAtCost(t, bcrypt.MinCost) + mid := hashAtCost(t, bcrypt.MinCost+2) + + for _, tc := range []struct { + name string + users map[string]config_util.Secret + expected int + }{ + { + name: "no users", + users: nil, + expected: bcrypt.MinCost, + }, + { + name: "single user", + users: map[string]config_util.Secret{"alice": mid}, + expected: bcrypt.MinCost + 2, + }, + { + name: "highest cost wins", + users: map[string]config_util.Secret{"alice": cheap, "bob": mid}, + expected: bcrypt.MinCost + 2, + }, + { + name: "unparseable hashes are skipped", + users: map[string]config_util.Secret{"alice": "not a hash", "bob": mid}, + expected: bcrypt.MinCost + 2, + }, + { + name: "only unparseable hashes", + users: map[string]config_util.Secret{"alice": "not a hash"}, + expected: bcrypt.MinCost, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := maxBcryptCost(tc.users); got != tc.expected { + t.Errorf("maxBcryptCost() = %d, expected %d", got, tc.expected) + } + }) + } +} + +// TestDecoyHashesForCost checks that the decoy carries the requested cost, which +// is what makes its comparison take as long as a configured user's, and that it +// is generated only once per cost. +func TestDecoyHashesForCost(t *testing.T) { + d := newDecoyHashes() + + hash, err := d.forCost(bcrypt.MinCost) + if err != nil { + t.Fatalf("forCost: %v", err) + } + cost, err := bcrypt.Cost(hash) + if err != nil { + t.Fatalf("Cost: %v", err) + } + if cost != bcrypt.MinCost { + t.Errorf("decoy cost = %d, expected %d", cost, bcrypt.MinCost) + } + + again, err := d.forCost(bcrypt.MinCost) + if err != nil { + t.Fatalf("forCost: %v", err) + } + if string(again) != string(hash) { + t.Error("forCost generated a second hash for a cost it had already produced") + } +} + +// TestDecoyHashesRejectsInvalidCost checks that an unusable cost is reported +// rather than silently producing a hash at some other cost. +func TestDecoyHashesRejectsInvalidCost(t *testing.T) { + if _, err := newDecoyHashes().forCost(bcrypt.MaxCost + 1); err == nil { + t.Error("expected an error for a cost above bcrypt.MaxCost") + } +} diff --git a/web/handler.go b/web/handler.go index 0a2718d5..0427e749 100644 --- a/web/handler.go +++ b/web/handler.go @@ -23,10 +23,16 @@ import ( "strings" "sync" + config_util "github.com/prometheus/common/config" "golang.org/x/crypto/bcrypt" "golang.org/x/time/rate" ) +// decoyPassword is hashed to produce the hash that requests for unknown users +// are compared against. Its value is irrelevant; only the cost of hashing it +// matters. +const decoyPassword = "fakepassword" + // extraHTTPHeaders is a map of HTTP headers that can be added to HTTP // responses. // This is private on purpose to ensure consistency in the Prometheus ecosystem. @@ -76,6 +82,54 @@ HeadersLoop: return nil } +// maxBcryptCost returns the highest bcrypt cost among the configured users, or +// bcrypt.MinCost if none of them can be parsed. Hashes are validated by +// validateUsers before the server starts, so an unparseable hash here means the +// configuration was changed to an invalid one after startup. +func maxBcryptCost(users map[string]config_util.Secret) int { + cost := bcrypt.MinCost + for _, hash := range users { + if c, err := bcrypt.Cost([]byte(hash)); err == nil { + cost = max(cost, c) + } + } + return cost +} + +// decoyHashes caches one bcrypt hash per cost, used for requests naming a user +// that is not configured. +// +// The cache is process-wide rather than per handler: the hashes are derived +// from a fixed password and hold no configuration, and generating one at a high +// cost is expensive enough to be worth doing only once. +type decoyHashes struct { + mtx sync.Mutex + hashes map[int][]byte +} + +var defaultDecoyHashes = newDecoyHashes() + +func newDecoyHashes() *decoyHashes { + return &decoyHashes{hashes: make(map[int][]byte)} +} + +// forCost returns a hash with the given cost, generating it on first use. The +// generation happens at most once per cost for the lifetime of the process. +func (d *decoyHashes) forCost(cost int) ([]byte, error) { + d.mtx.Lock() + defer d.mtx.Unlock() + + if hash, ok := d.hashes[cost]; ok { + return hash, nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(decoyPassword), cost) + if err != nil { + return nil, err + } + d.hashes[cost] = hash + return hash, nil +} + type webHandler struct { tlsConfigPath string handler http.Handler @@ -115,10 +169,24 @@ func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { hashedPassword, validUser := c.Users[user] if !validUser { - // The user is not found. Use a fixed password hash to - // prevent user enumeration by timing requests. - // This is a bcrypt-hashed version of "fakepassword". - hashedPassword = "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSi" + // The user is not found. Compare against a decoy hash so + // that the request takes about as long as one naming a + // configured user, which prevents user enumeration by + // timing requests. + // + // The decoy is generated at the highest cost in use, so a + // request for an unknown user is never answered faster + // than one for a configured user. Where the configured + // costs differ, the cheaper users are still answered + // faster than the decoy; using a single cost for every + // user avoids that. + decoy, err := defaultDecoyHashes.forCost(maxBcryptCost(c.Users)) + if err != nil { + u.logger.Error("Unable to generate decoy password hash", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + hashedPassword = config_util.Secret(decoy) } cacheKey := strings.Join( diff --git a/web/testdata/web_config_users.good.yml b/web/testdata/web_config_users.good.yml index feeee187..a970d89b 100644 --- a/web/testdata/web_config_users.good.yml +++ b/web/testdata/web_config_users.good.yml @@ -3,6 +3,6 @@ tls_server_config: key_file: "server.key" basic_auth_users: alice: $2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby - bob: $2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR. + bob: $2y$14$y7HJ017Lo/wKPDdzdHahNevjaqE.WRk3iKVLLt1yKO7RtIrNbvy6. carol: $2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu dave: $2y$10$2UXri9cIDdgeKjBo4Rlpx.U3ZLDV8X1IxKmsfOvhcM5oXQt/mLmXq diff --git a/web/testdata/web_config_users_noTLS.good.yml b/web/testdata/web_config_users_noTLS.good.yml index d3a79871..9641fcb7 100644 --- a/web/testdata/web_config_users_noTLS.good.yml +++ b/web/testdata/web_config_users_noTLS.good.yml @@ -1,5 +1,5 @@ basic_auth_users: alice: $2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby - bob: $2y$18$4VeFDzXIoPHKnKTU3O3GH.N.vZu06CVqczYZ8WvfzrddFU6tGqjR. + bob: $2y$14$y7HJ017Lo/wKPDdzdHahNevjaqE.WRk3iKVLLt1yKO7RtIrNbvy6. carol: $2y$10$qRTBuFoULoYNA7AQ/F3ck.trZBPyjV64.oA4ZsSBCIWvXuvQlQTuu dave: $2y$10$2UXri9cIDdgeKjBo4Rlpx.U3ZLDV8X1IxKmsfOvhcM5oXQt/mLmXq