web: generate the basic auth decoy hash at the cost in use - #445
Open
mrueg wants to merge 1 commit into
Open
Conversation
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
force-pushed
the
fix/basic-auth-decoy-hash-cost
branch
from
September 2, 2026 13:44
74e63e2 to
54733d1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
maxBcryptCostincluding unparseable hashes, and tests that the decoy carries the requested cost and is generated once per cost.🤖 Generated with Claude Code