Skip to content

fix(core): case-fold the username before consulting the ceremony rate limiter - #148

Merged
wolpert merged 1 commit into
mainfrom
fix/rate-limit-username-case
Aug 5, 2026
Merged

fix(core): case-fold the username before consulting the ceremony rate limiter#148
wolpert merged 1 commit into
mainfrom
fix/rate-limit-username-case

Conversation

@wolpert

@wolpert wolpert commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Closes finding 3 of the full-project security audit.

The problem

UserLookup implementations resolve usernames case-insensitively — DynamoDbUserLookup lower-cases the identity key — but rateLimitedBucket passed the raw request string to CeremonyRateLimiter.tryAcquireForUsername:

if (username != null && !rateLimiter.tryAcquireForUsername(username)) {

So alice, Alice, and ALICE drew on three independent per-username budgets against one account. An 8-character username yields 256 of them, turning the 10/min allowance into roughly 2560/min.

The per-IP bucket does not compensate. It caps a single source at 30/min, but the per-username bucket exists precisely for the distributed case — many sources, one victim — and that is exactly the case the split defeated.

The fix

Case-fold (toLowerCase(Locale.ROOT)) at the call site, not inside InMemoryCeremonyRateLimiter. That way every implementation inherits the fix — including a host's shared Redis limiter, which had the same bug and no way to know it. The SPI now documents that the argument arrives folded and must be used as given rather than re-derived from a differently-cased source.

Why folding is safe even on a case-sensitive backend

Worst case, two genuinely distinct accounts share one throttle bucket — stricter, not weaker. (#5 in this series makes the JDBI backend case-insensitive too, at which point the question is moot.)

Testing

usernameBucketKeyIsCaseFoldedSoVariantsShareOneBudget() drives Alice / ALICE / aLiCe through both start ceremonies and asserts all three land on the single key alice. ./gradlew :pk-auth-core:check passes.

🤖 Generated with Claude Code

@wolpert
wolpert force-pushed the fix/rate-limit-username-case branch from 0d03c6e to 97c32d4 Compare August 5, 2026 15:12
@wolpert
wolpert enabled auto-merge (rebase) August 5, 2026 15:19
… limiter

UserLookup implementations resolve usernames case-insensitively — DynamoDbUserLookup
lower-cases the identity key — but rateLimitedBucket passed the raw request string
to CeremonyRateLimiter.tryAcquireForUsername. So "alice", "Alice", and "ALICE" drew
on three independent per-username budgets against one account; an 8-character
username yields 256 of them, turning a 10/min allowance into ~2560/min.

The per-IP bucket does not compensate. It caps a single source at 30/min, but the
per-username bucket exists precisely for the distributed case — many sources, one
victim — and that is the case the split defeated.

Folded at the call site rather than inside InMemoryCeremonyRateLimiter so every
implementation inherits the fix, including a host's shared Redis limiter, which
had the same bug and no way to know it. The SPI now documents that the argument
arrives folded and must be used as given.

Case-folding is the safe direction even where a backend is still case-sensitive:
the worst case is two genuinely distinct accounts sharing one throttle bucket,
which is stricter, not weaker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wolpert
wolpert force-pushed the fix/rate-limit-username-case branch from 97c32d4 to fe056cc Compare August 5, 2026 15:21
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@wolpert
wolpert merged commit 8ae507c into main Aug 5, 2026
8 checks passed
@wolpert
wolpert deleted the fix/rate-limit-username-case branch August 5, 2026 15:25
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