Skip to content

fix(security): share the login limits across every spelling of an account - #23

Merged
knittingcode merged 6 commits into
mainfrom
fix/fcrm-auto-login-limit-key
Oct 24, 2026
Merged

knittingcode merged 6 commits into
mainfrom
fix/fcrm-auto-login-limit-key

Conversation

@knittingcode

@knittingcode knittingcode commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

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)

  • The mismatch:
    • The user lookup is case-insensitive through PostgreSQL lower(email) = lower(?).
    • The limiter key used PHP's full Unicode mapping, mb_strtolower.
    • For İ (U+0130), PostgreSQL gives i and PHP gives i + U+0307. A final capital sigma also differs.
  • Proven end to end against real PHP-FPM and PostgreSQL:
    • casİng-…@example.test with the right password logs into the account casing-…@example.test.
    • From one IP, 5 wrong passwords with the plain address and then 5 more with the İ spelling all answered 401 before 429: 10 failures in 15 minutes instead of 5.
    • Each i can be swapped independently, so an address with k is had 2^k windows. That multiplies both the 5-per-15-minutes and the 100-per-day account limits.
    • The per-IP limit of 50 per 15 minutes was not affected.
  • How it was found: a read-only gap analysis of the security tests, followed by an exhaustive comparison of PostgreSQL lower() and PHP's mappings over all 1,112,063 non-surrogate code points.
  • Policy: the fix enforces the existing, documented per-account limits. No limit or policy changes.

Changes

  • LoginRateLimiter::account():
    • Now uses 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.
    • 124 characters that PostgreSQL keeps distinct now share a window, which only makes the limits stricter.
    • The docblock records the locale dependency and, like the guide, says "at least as far as the lookup" rather than "exactly like" it, because of those 124 characters.
  • Unit test LoginRateLimiterTest::testSpellingsThatTheLookupFoldsTogetherShareTheAccountWindow: İ and final sigma, in upper and lower case.
  • Integration test 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.
  • Negative control "login key folds case unlike the user lookup", which reverts to mb_strtolower. It sits in the login throttling section of controls.php.
  • docs/architecture/authentication.md: the key's case folding and the locale dependency.
  • LoginFailureHandler: the failed-login log pseudonym account_ref now folds case the same way, so every spelling of one account has one pseudonym.

Testing

  • Local, disposable Compose project (head c1630e9): LoginThrottlingTest, AuthenticationTest and SecurityLoggingTest OK (28 tests, 601 assertions). The 4 login controls are all detected; the new one fails 5 tests.
  • Exhaustive folding check, repeated in a throwaway postgres:18.6-alpine3.24 container (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 that lower() folds together gets two keys.
  • Static checks: unit (116), PHPStan, PHP-CS-Fixer, Deptrac and the architecture tests are clean.
  • Without the fix, the integration test fails: the variant answers 200 where 429 is expected.
  • CI on the exact head is the reference.

Known limitations

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
knittingcode merged commit 1683e16 into main Oct 24, 2026
2 checks passed
@knittingcode
knittingcode deleted the fix/fcrm-auto-login-limit-key branch October 24, 2026 13:11
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
Main has 38 controls with #23 and #16; this branch adds three, so the
documented count is 41.
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.
knittingcode added a commit that referenced this pull request 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.
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