Skip to content

feat(names): filter display names on a canonical form instead of a hardcoded list - #53

Open
doopey655 wants to merge 1 commit into
GeneralsOnlineDevelopmentTeam:mainfrom
nathan-soul:feature/name-filter
Open

feat(names): filter display names on a canonical form instead of a hardcoded list#53
doopey655 wants to merge 1 commit into
GeneralsOnlineDevelopmentTeam:mainfrom
nathan-soul:feature/name-filter

Conversation

@doopey655

@doopey655 doopey655 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

The display name filter is an inline List<string> in WebSocketController matched with
Contains(), so Нitler (Cyrillic Н), HITLER, h.i.t.l.e.r and hiiitler all pass, while
ibra rejects Ibrahim.

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 as action, match, pattern, category
(allow/block/review/shadow × skeleton/word/exact). It ships with the same 10 rules the
hardcoded list enforced. Edit the file and run !namefilter reload; a rule's id is its line number,
so !namefilter list and the scan report point at the line to change. A malformed line is logged
with 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_skeleton for homoglyph impersonation, a reject log instead of telling the
user which pattern matched, rate limiting, and !namefilter rescan for 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; the ALTER fails if it has
already been applied, which is the intended signal.

Migration
CREATE TABLE IF NOT EXISTS `name_filter_rejects` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) NOT NULL,
  `attempted_name` varchar(64) NOT NULL DEFAULT '',
  `skeleton` varchar(64) NOT NULL DEFAULT '',
  `rule_id` int(11) NOT NULL DEFAULT -1,
  `action` tinyint(4) NOT NULL DEFAULT 1,
  `source` tinyint(4) NOT NULL DEFAULT 0,
  `created` datetime NOT NULL DEFAULT current_timestamp(),
  PRIMARY KEY (`id`),
  KEY `idx_user` (`user_id`),
  KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

ALTER TABLE `users`
  ADD COLUMN `displayname_skeleton` varchar(32) DEFAULT NULL AFTER `displayname`,
  ADD INDEX `idx_displayname_skeleton` (`displayname_skeleton`);

@doopey655
doopey655 force-pushed the feature/name-filter branch from 0c0dd36 to 2376999 Compare August 25, 2026 20:23
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class NameFilterRule

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a resource file would work much better here than a database.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@doopey655
doopey655 force-pushed the feature/name-filter branch from 2376999 to 505af05 Compare August 26, 2026 09:33
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.

2 participants