Better username censoring (#3122)

## Description:

Many inapropriate names bypass the current filter. This PR does the
following:

1. Moves name censoring to server side so inappropriate names are
scrubbed before being sent to the client
2. Requests a list of profane words from the api, this allows us to
quickly add new profane words in the admin panel without having to
redeploy.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-02-09 21:05:59 -08:00
committed by GitHub
parent f7da20ddfd
commit 900cc89067
10 changed files with 330 additions and 163 deletions
+119 -1
View File
@@ -1,3 +1,14 @@
import {
DataSet,
RegExpMatcher,
collapseDuplicatesTransformer,
englishDataset,
pattern,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from "obscenity";
import { Cosmetics } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import {
@@ -7,6 +18,95 @@ import {
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
export const shadowNames = [
"UnhuggedToday",
"DaddysLilChamp",
"BunnyKisses67",
"SnugglePuppy",
"CuddleMonster67",
"DaddysLilStar",
"SnuggleMuffin",
"PeesALittle",
"PleaseFullSendMe",
"NanasLilMan",
"NoAlliances",
"TryingTooHard67",
"MommysLilStinker",
"NeedHugs",
"MommysLilPeanut",
"IWillBetrayU",
"DaddysLilTater",
"PreciousBubbles",
"67 Cringelord",
"Peace And Love",
"AlmostPottyTrained",
];
export function createMatcher(bannedWords: string[]): RegExpMatcher {
const customDataset = new DataSet<{ originalWord: string }>().addAll(
englishDataset,
);
for (const word of bannedWords) {
customDataset.addPhrase((phrase) =>
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
);
}
return new RegExpMatcher({
...customDataset.build(),
blacklistMatcherTransformers: [
toAsciiLowerCaseTransformer(),
resolveConfusablesTransformer(),
resolveLeetSpeakTransformer(),
collapseDuplicatesTransformer(),
skipNonAlphabeticTransformer(),
],
});
}
/**
* Sanitizes and censors profane usernames and clan tags.
* Profane username is overwritten, profane clan tag is removed.
*
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
* - full name including clan tag was overwritten in the past, if any part of name was bad
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
*
* Examples:
* - "GoodName" -> "GoodName"
* - "BadName" -> "Censored"
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
* - "[CLaN]BadName" -> "[CLAN] Censored"
* - "[BAD]GoodName" -> "GoodName"
* - "[BAD]BadName" -> "Censored"
*/
function censorUsernameWithMatcher(
username: string,
matcher: RegExpMatcher,
): string {
const clanTag = getClanTagOriginalCase(username);
const nameWithoutClan = clanTag
? username.replace(`[${clanTag}]`, "").trim()
: username;
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
const censoredName = usernameIsProfane
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
: nameWithoutClan;
// Restore clan tag only if it's clean, otherwise remove it entirely
if (clanTag && !clanTagIsProfane) {
return `[${clanTag.toUpperCase()}] ${censoredName}`;
}
return censoredName;
}
type CosmeticResult =
| { type: "allowed"; cosmetics: PlayerCosmetics }
@@ -14,13 +114,19 @@ type CosmeticResult =
export interface PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
censorUsername(username: string): string;
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
private matcher: RegExpMatcher;
constructor(
private cosmetics: Cosmetics,
private b64urlDecode: (base64: string) => Uint8Array,
) {}
bannedWords: string[],
) {
this.matcher = createMatcher(bannedWords);
}
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
const cosmetics: PlayerCosmetics = {};
@@ -106,10 +212,22 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
return { color };
}
censorUsername(username: string): string {
return censorUsernameWithMatcher(username, this.matcher);
}
}
// Default matcher with no custom banned words (just englishDataset)
const defaultMatcher = createMatcher([]);
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
return { type: "allowed", cosmetics: {} };
}
censorUsername(username: string): string {
// Fail open: use matcher with just the built-in English profanity dataset
return censorUsernameWithMatcher(username, defaultMatcher);
}
}