feat(names): filter display names on a canonical form instead of a hardcoded list - #53
Conversation
0c0dd36 to
2376999
Compare
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.EntityFrameworkCore.Metadata.Builders; | ||
|
|
||
| public class NameFilterRule |
There was a problem hiding this comment.
I think a resource file would work much better here than a database.
There was a problem hiding this comment.
Agreed, and done — the rules are now data/namefilter_rules.txt rather than a table. Nothing joined against it in SQL, the rules are cached in memory after startup anyway, and data/ is already where this repo keeps its config. It ships in the publish output next to motd.txt, so it can be edited on the host and picked up with !namefilter reload.
One consequence worth naming: nothing in the service writes to data/, so there are no bot commands that add or remove rules — a rule change is an edit to the file. !namefilter decisions now prints the allow lines to paste in instead of writing them itself. Rule ids are line numbers in the file, so list and the scan report point at the line to edit.
The rules table and its seed are out of the migration; name_filter_rejects and users.displayname_skeleton are still there.
…rdcoded list The display name filter is an inline List<string> in WebSocketController matched with Contains(), so it only catches spellings somebody thought of. Нitler with a Cyrillic Н, HITLER in fullwidth, Hıtler with a dotless i, h.i.t.l.e.r, hiiitler and a name with a zero-width space in it all pass today. The list answers that by enumerating h1tler, h1tl3r, hittler, h1ttler, h1ttl3r by hand, which is a losing game and still only covers ASCII. Three more problems in the same block: the rejection message prints the pattern that matched, which tells the user what to mutate; substring matching with no word mode means the ibra guard rejects Ibrahim; and uniqueness is DisplayName.ToLower() equality, so Rоnin with a Cyrillic о is a distinct string that renders identically in a lobby list. Names are now matched on a canonical form. NameSkeleton normalizes (NFKD, combining marks dropped, UTS GeneralsOnlineDevelopmentTeam#39 confusables folded to ASCII, lowercased) and then skeletonizes (leet fold, repeat runs collapsed, non-alphanumerics removed, capped at the stored length). Rule patterns go through the same fold, so both sides land in the same alphabet and every spelling above reduces to one pattern. Rules live in data/namefilter_rules.txt, one per line as action, match, pattern and category, with four actions (allow, block, review, shadow) and three match types (skeleton substring, word boundary on the normalized text, exact skeleton). They are read at startup and on !namefilter reload, and a rule's id is its line number, so a report points at the line to edit. A malformed line is logged with its line number and skipped; a file that cannot be read leaves the rules already in memory in force. It ships with the ten rules the hardcoded list enforced, the impersonation guards as exact matches, which is what stops them swallowing unrelated names. Around that: a structural gate for length and for control, zero-width and bidi characters; users.displayname_skeleton with uniqueness checked against it; name_filter_rejects instead of naming the rule back to the user; and a per-user rate limit that only counts rules and structural failures, not typos. !namefilter in the admin channel covers test, list, categories, reload, and - for names that predate a rule, since rules only ever applied at name change time - rescan, scanreport and decisions. The scan report is one row per distinct name rather than per account, because one name covers everyone whose name folds onto it. Renaming those accounts is opt in and needs an explicit confirm, and a keep verdict reports an allow line to add to the rules file rather than writing one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2376999 to
505af05
Compare
The display name filter is an inline
List<string>inWebSocketControllermatched withContains(), soНitler(Cyrillic Н),HITLER,h.i.t.l.e.randhiiitlerall pass, whileibrarejectsIbrahim.Names are now matched on a canonical form — NFKD, Unicode confusables folded to ASCII, leet fold,
repeats collapsed, non-alphanumerics stripped. Rule patterns get the same fold, so those spellings
reduce to one pattern.
Rules live in
data/namefilter_rules.txt, one per line asaction,match,pattern,category(
allow/block/review/shadow×skeleton/word/exact). It ships with the same 10 rules thehardcoded list enforced. Edit the file and run
!namefilter reload; a rule's id is its line number,so
!namefilter listand the scan report point at the line to change. A malformed line is loggedwith its line number and skipped, and a file that cannot be read at all leaves the rules already
loaded in force rather than turning the filter off.
Also:
users.displayname_skeletonfor homoglyph impersonation, a reject log instead of telling theuser which pattern matched, rate limiting, and
!namefilter rescanfor names that predate a rule.Run this before starting the service (also in the diff as
Database_Structure/migrations/2026-08-24-name-filter.sql). MySQL 8; theALTERfails if it hasalready been applied, which is the intended signal.
Migration