Repository navigation
fix(security): share the login limits across every spelling of an account - #23
Merged
Merged
Conversation
The login limiter keyed the account with mb_strtolower (full Unicode case mapping) while the user lookup compares PostgreSQL lower() values. U+0130 and a final capital sigma fold differently, so spellings that load the same account got separate account and account-and-IP windows: one client could make 5 failed attempts per spelling instead of 5 in total, and 2^k spellings exist for an address with k letters i. The key now uses the simple case mapping, which matches lower() for every code point in the database locale.
…count The integration test logs in with a U+0130 variant of the address and checks that all spellings that resolve to the account use one window. The negative control restores the full case mapping in the limiter key.
…udonym The account_ref of a failed login used mb_strtolower, so an address with U+0130 got a different pseudonym than the account it logs into. It now folds case like the user lookup and the login limiter key.
An exhaustive comparison of PHP's simple lower-case mapping with PostgreSQL lower() under the database locale (libc, en_US.utf8) found no two spellings that lower() folds together with different limiter keys, but 124 code points (circled letters, for example) that the key folds and lower() does not. Those spellings share an account's windows without loading it, which only makes the limit stricter; the comment and the authentication guide no longer call the two foldings identical, and name C library and PHP Unicode table changes as reasons to repeat the check. The integration test's fifth spelling upper-cased the address before inserting U+0130, so it repeated the third; it now inserts U+0130 first.
knittingcode
added a commit
that referenced
this pull request
Oct 24, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 24, 2026
The redaction table lists the URL keys whose relative and absolute values lose their query. The negative-control count is 38 with #23 and this branch.
knittingcode
added a commit
that referenced
this pull request
Oct 24, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 24, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 24, 2026
Conflict in docs/architecture/authentication.md: kept this branch's unit test row; the control count is main's 38 plus this branch's 10.
This was referenced Oct 24, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 24, 2026
…controls Seven negative controls for protections on main that no control covered: URL fragments under URL keys, the referer key, values of sensitive keys, database driver messages, the failed-login pseudonym folding (#23), and the member list order and total. Two tests close the gaps the fragment and pseudonym controls exposed. The documented count becomes 48.
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
Negative-control analyses found protections on main whose regressions no test would catch, and protections that were tested but had no control. RouteSecurityTest pins the exact permission of every tenant route, so a weakened or new tenant route must be added on purpose. AuditTrailTest checks that the operator's disable, enable and reset-password are audited with the operator as actor and no secrets, and that enabling a user, creating a user or an organization and adding a membership store nothing when their audit event cannot be written. SecurityLoggingTest checks that the spellings of an address that reach one account share one pseudonym (#23), and JsonLineFormatterTest that a URL key loses its fragment even without a query (#16). Ten new negative controls (53 with main's 43): a tenant route weakened to another permission, enabling a user without an audit event or outside its transaction, URL fragments under URL keys, the referer key, values of sensitive keys, database driver messages, the failed-login pseudonym folding, and the member list order and total. Tests and documentation only; no production code changes. When the membership administration API (#19) is merged after this, its three membership routes must be added to the pinned map (the test fails otherwise) and the documented control count recounted.
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.
Summary
Fixes a bypass of the per-account login limits. Some spellings of an e-mail address load the same account but got login-limiter windows of their own. The limiter key now folds case at least as far as the user lookup: every spelling the lookup folds together gets one key.
Motivation and context (security)
lower(email) = lower(?).mb_strtolower.İ(U+0130), PostgreSQL givesiand PHP givesi+ U+0307. A final capital sigma also differs.casİng-…@example.testwith the right password logs into the accountcasing-…@example.test.İspelling all answered 401 before 429: 10 failures in 15 minutes instead of 5.ican be swapped independently, so an address with kis had 2^k windows. That multiplies both the 5-per-15-minutes and the 100-per-day account limits.lower()and PHP's mappings over all 1,112,063 non-surrogate code points.Changes
LoginRateLimiter::account():mb_convert_case(…, MB_CASE_LOWER_SIMPLE, 'UTF-8'). Under the database locale (libc,en_US.utf8), no code point now splits an account across windows.LoginRateLimiterTest::testSpellingsThatTheLookupFoldsTogetherShareTheAccountWindow:İand final sigma, in upper and lower case.LoginThrottlingTest::testEveryVariantThatResolvesToTheAccountSharesItsWindow: plain,İ, upper case,I, andİin an upper-case address. The last spelling previously upper-cased before insertingİand so repeated the third one; it is now distinct.mb_strtolower. It sits in the login throttling section ofcontrols.php.docs/architecture/authentication.md: the key's case folding and the locale dependency.LoginFailureHandler: the failed-login log pseudonymaccount_refnow folds case the same way, so every spelling of one account has one pseudonym.Testing
c1630e9):LoginThrottlingTest,AuthenticationTestandSecurityLoggingTestOK (28 tests, 601 assertions). The 4 login controls are all detected; the new one fails 5 tests.postgres:18.6-alpine3.24container (libc,en_US.utf8) against PHP 8.5 in the API image: 1,112,063 code points, 124 differ, all in the stricter direction; no spelling thatlower()folds together gets two keys.Known limitations