Skip to content

web: generate the basic auth decoy hash at the cost in use - #445

Open
mrueg wants to merge 1 commit into
prometheus:masterfrom
mrueg:fix/basic-auth-decoy-hash-cost
Open

web: generate the basic auth decoy hash at the cost in use#445
mrueg wants to merge 1 commit into
prometheus:masterfrom
mrueg:fix/basic-auth-decoy-hash-cost

Conversation

@mrueg

@mrueg mrueg commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

When a request names a user that is not configured, the handler compares the supplied password against a fixed hash so 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 is hardcoded at bcrypt cost 10, while the real users' hashes use whatever cost the operator chose. Whenever they chose a higher one, the mitigation does not hold.

Measured with users hashed at cost 14:

request time
rejected, configured user 1.12 s
rejected, unknown user 70 ms

A 16× gap, trivially readable over a network. The repository's own fixtures use costs 12 and 18, and the docs recommend up to 18.

Change

Generate the decoy at the highest cost among the configured users, 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 docs.

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.

What this costs, stated plainly

This makes rejected requests as expensive as the most expensive configured user. That is the point — the two cases have to cost the same — but it does raise the cost of an unauthenticated request from a fixed ~70 ms to whatever the highest configured cost is. Measured here: 286 ms at cost 12, 1.1 s at cost 14, 18 s at cost 18.

A wrong password for a known username already cost that much before this change, so the exposure is not new; it just no longer requires knowing a username. The documentation now says so and points at rate_limit.

Fixture change

Both shared user fixtures carried a cost-18 entry for a user no test ever authenticates as. With the decoy tracking the highest cost, that entry alone added ~70 s to the suite. It is lowered to cost 14, which keeps the fixtures covering a spread of costs (10, 12, 14) at a fraction of the runtime. Suite time goes from 10.5 s on master to 14.7 s here.

Tests

Table test for maxBcryptCost including unparseable hashes, and tests that the decoy carries the requested cost and is generated once per cost.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Signed-off-by: Manuel Rüger <manuel@rueg.eu>
@mrueg
mrueg force-pushed the fix/basic-auth-decoy-hash-cost branch from 74e63e2 to 54733d1 Compare September 2, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant