Skip to content

fix(core): bound username length and cap the in-memory rate-limiter caches - #149

Merged
wolpert merged 1 commit into
mainfrom
fix/unbounded-username-and-caches
Aug 5, 2026
Merged

fix(core): bound username length and cap the in-memory rate-limiter caches#149
wolpert merged 1 commit into
mainfrom
fix/unbounded-username-and-caches

Conversation

@wolpert

@wolpert wolpert commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Closes finding 4 of the full-project security audit.

The problem

Two halves of the same unbounded-growth problem on the permitAll start endpoints.

Nothing capped username length — StartRegistrationRequest checked only non-blank, and the JDBI column is Postgres TEXT. Both Caffeine caches were built with expireAfterWrite and no maximumSize:

this.ipCounters = Caffeine.newBuilder().expireAfterWrite(window).build();

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: startRegistration passes it to UserLookup#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.
  • InMemoryCeremonyRateLimiter and InMemoryWindowCounter 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.

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 | RateLimited only — adding a ValidationFailed variant 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, overlongUsernameIsRejectedAsBadRequestNotServerError drives both start endpoints through the Spring adapter and expects 400 — it passes, confirming Jackson's ValueInstantiationException maps 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

StartAuthenticationRequest keeps accepting a null username (usernameless / discoverable-credential flow) and deliberately does not adopt the non-blank rule — an unknown username already yields the same empty allowCredentials shape 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:check passes.

🤖 Generated with Claude Code

…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
wolpert force-pushed the fix/unbounded-username-and-caches branch from 51af7ce to e0d22d2 Compare August 5, 2026 15:12
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@wolpert
wolpert merged commit ef4af92 into main Aug 5, 2026
8 checks passed
@wolpert
wolpert deleted the fix/unbounded-username-and-caches branch August 5, 2026 15:18
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