thoka
(Thomas Kalka)
April 22, 2026, 7:47pm
1
While investigating Private Topics Plugin - #109 by thoka I stumbled upon the fact, that a mention for a user in a restricted category is not reported, when the username contains uppercase letters.
If I mention @SomeUser, the editor requests
/composer/mentions.json?names[]=SomeUser&topic_id=10728
in the result the username is returned in lowercase, without user_reasons set.
A query for the username in lowercase letters returns "user_reasons": {"someuser":"category"}.
If I use lowercase letters for the usernames in the composer, warnings for people with not sufficient rights will be shown.
If one use the autocompletion provided by the editor, the typed lowercase usernames will be replaced by uppercase names and therefore not reported.
3 Likes
RGJ
(Richard - Communiteq)
April 22, 2026, 9:26pm
2
Nice find @thoka !
The problem is here
users returns {"username_lower" => User object }
However if name is not downcased, users[name] does not exist.
Fix:
if user = users[name.downcase]
...
elsif group = groups[name.downcase]
...
Or better: downcase all names at the start of the method because there are a lot of issues in there, groups nicely does .where("lower(name) IN (?)", @names.map(&:downcase)) but functions like visible_group_ids_for_allowed_check, topic_allowed_group_ids, mentionable_group_ids and members_visible_group_ids all do where(name: @names) which introduces case sensitivity issues as well.
3 Likes
The correct fix is
main ← fix/unicode-username-lookups
closed 02:15AM - 14 Apr 26 UTC
Leverages Rails' built-in `normalizes` feature to handle username
normalization … consistently throughout the codebase. When you call
`User.where(username_lower: value)` or `find_by(username_lower: value)`,
ActiveRecord now automatically normalizes the input value.
Key changes:
- Adds `normalizes :username` (unicode normalize) and
`normalizes :username_lower` (unicode normalize + downcase) to User
- Adds `normalizes :email` (strip + downcase) to UserEmail
- Removes manual `.downcase` and `.map(&:downcase)` calls before AR queries
- Adds `User#matches_username?` method for comparing usernames
- Simplifies `filter_by_username` and `filter_by_username_or_email` scopes
to use `ILIKE ANY(ARRAY[?])` instead of branching on array vs single value
- Updates `filter_by_username` to normalize input for ILIKE patterns
- Updates `find_by_username` to rely on AR normalization
- Fixes a SQL injection vulnerability in search user ordering
- Uses before_save callback for username_lower assignment to ensure it
runs even when validation is skipped (e.g., finish installation flow)
- Adds shoulda matcher tests for username normalizations
The `normalize_username` class method is kept for cases where AR can't help:
raw SQL queries, ILIKE patterns, and direct comparisons.
Ref - https://meta.discourse.org/t/393646
but it’s too big a change than I’m confortable merging at this point
Instead, I’m going to fix each “endpoints” one by one to make it simpler to review and less risky.
Here’s first step
main ← fix-composer-mention-case-sensitivity
opened 08:30AM - 23 Apr 26 UTC
Mentioning `@SomeUser` in a topic the mentioned user can't see (restricted categ… ory, PM they aren't invited to, topic they've muted) silently skipped the "cannot see this mention" warning popup whenever the typed name contained any uppercase letter. Same for mixed-case group names. Mention validation itself worked because of an existing client-side `.toLowerCase()` workaround, so the bug was easy to miss — mentions stayed `<a class="mention">` but the user got no signal that the mentioned account wouldn't actually be notified.
Server side, `ComposerController#mentions` builds its `users` lookup keyed by `username_lower` and `groups` keyed by the case-preserved DB `name`, then iterated `@names.each { |n| users[n] || groups[n] }` without normalizing — so any uppercase character missed both hashes and `user_reasons`/`group_reasons` came back empty. Four downstream group helpers (`mentionable_group_ids`, `members_visible_group_ids`, `topic_allowed_group_ids`, `visible_group_ids_for_allowed_check`) used `where(name: @names)` which is case-sensitive in PostgreSQL, and the `SiteSetting.here_mention` membership test compared raw strings.
Client side, `link-mentions.js` cached `foundUsers` / `userReasons` / `foundGroups` / `groupReasons` by the case as typed, and the prosemirror `mention.js` warning lookup did `response.users.includes(name)` plus `response.user_reasons[name]` with the original case — both of which the server only ever returned in the casing it had on hand.
Normalize the controller's `@names` and `@allowed_names` once at the top of the action, switch all four group helpers to `LOWER(name) IN (?)`, lower-case the response keys, and lower-case client caches and lookups end to end. Also extract the inline notified-member query into `already_notified_member_count` and tighten the request specs to cover users, mentionable groups, group reasons in PMs, and `allowed_names` (both user and group branches) with mixed-case input.
https://meta.discourse.org/t/401292
4 Likes