fix(core): bound username length and cap the in-memory rate-limiter caches - #149
Merged
Conversation
…aches Two halves of the same unbounded-growth problem on the permitAll start endpoints. Nothing capped username length anywhere — StartRegistrationRequest checked only non-blank and the JDBI column is Postgres TEXT — while both Caffeine caches were built with expireAfterWrite and no maximumSize. Every distinct key is retained for the whole window, so the maps grow with the caller's key variety rather than with the number of real users, and each entry is as large as the username sent. The username also outlives the request in a second way: startRegistration passes it to UserLookup#getOrCreateHandle, which persists a user row before any credential exists, on an endpoint that requires no authentication. StartRegistrationRequest.MAX_USERNAME_LENGTH (256 — comfortably covers an email address used as a username) now bounds both start requests, and both cache maps cap at DEFAULT_MAX_TRACKED_KEYS (100_000). Caffeine evicts near-LRU at the cap; an evicted counter restarts, which costs at most one extra allowance to the least-active key and never grants an unbounded budget to an active one. Validation goes in the record's compact constructor, matching the non-blank check already there rather than adding a variant to the sealed start-result sums (which would break every adapter's exhaustive switch, including hosts'). That path was assumed to yield 400 rather than 500, so the assumption is now tested through the Spring adapter instead of asserted: overlongUsernameIsRejectedAsBadRequestNotServerError drives both start endpoints and expects 400. StartAuthenticationRequest keeps accepting a null username — that is the usernameless / discoverable-credential flow — and deliberately does not adopt the non-blank rule, since an unknown username already yields the same empty allowCredentials shape as a known one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wolpert
force-pushed
the
fix/unbounded-username-and-caches
branch
from
August 5, 2026 15:12
51af7ce to
e0d22d2
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.



Closes finding 4 of the full-project security audit.
The problem
Two halves of the same unbounded-growth problem on the
permitAllstart endpoints.Nothing capped username length —
StartRegistrationRequestchecked only non-blank, and the JDBI column is PostgresTEXT. Both Caffeine caches were built withexpireAfterWriteand nomaximumSize:Every distinct key is retained for the whole window, so the maps grow with the caller's key variety rather than with the number of real users — and each entry is as large as the username sent.
The username outlives the request a second way:
startRegistrationpasses it toUserLookup#getOrCreateHandle, which persists a user row before any credential exists, on an endpoint requiring no authentication.The fix
StartRegistrationRequest.MAX_USERNAME_LENGTH= 256 (comfortably covers an email address as username), enforced on both start requests.InMemoryCeremonyRateLimiterandInMemoryWindowCountercap atDEFAULT_MAX_TRACKED_KEYS= 100 000.Caffeine evicts near-LRU at the cap. An evicted counter restarts, which costs at most one extra allowance to the least-active key and never grants an unbounded budget to an active one.
Design note: why the record constructor, not a sealed variant
The scoping conversation called for "a clean 400 via the sealed result". The start-result sums are
Started | RateLimitedonly — adding aValidationFailedvariant would break every adapter's exhaustive switch, including hosts', for a validation concern. So validation goes in the record's compact constructor, matching the non-blank check already living there.That path was assumed to render as 400 rather than 500. Rather than assert it,
overlongUsernameIsRejectedAsBadRequestNotServerErrordrives both start endpoints through the Spring adapter and expects 400 — it passes, confirming Jackson'sValueInstantiationExceptionmaps correctly. If you'd still prefer the sealed variant, say so and I'll do it as a follow-up across the three adapters.Scope note
StartAuthenticationRequestkeeps accepting anullusername (usernameless / discoverable-credential flow) and deliberately does not adopt the non-blank rule — an unknown username already yields the same emptyallowCredentialsshape as a known one, so there's no enumeration signal to protect and tightening it would change behaviour for no gain.Testing
StartRequestUsernameBoundTest(both records, including at-limit boundaries and the null-username flow) plus the two adapter-level tests above../gradlew :pk-auth-core:check :pk-auth-spring-boot-starter:checkpasses.🤖 Generated with Claude Code