From 900cc89067f1fd72752d2325554e9a65a2d8bfae Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 9 Feb 2026 21:05:59 -0800 Subject: [PATCH] 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 --- src/client/ClientGameRunner.ts | 1 + src/core/GameRunner.ts | 3 +- src/core/game/GameView.ts | 8 ++ src/core/validations/username.ts | 83 ----------------- src/server/Client.ts | 1 + src/server/Privilege.ts | 120 ++++++++++++++++++++++++- src/server/PrivilegeRefresher.ts | 53 +++++++++-- src/server/Worker.ts | 8 ++ tests/Censor.test.ts | 69 --------------- tests/Privilege.test.ts | 147 +++++++++++++++++++++++++++++++ 10 files changed, 330 insertions(+), 163 deletions(-) create mode 100644 tests/Privilege.test.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 6b8f8ee52..ca7dfef7f 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -227,6 +227,7 @@ async function createClientGame( config, gameMap, clientID, + lobbyConfig.playerName, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, ); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 0f93a94f6..619fb2645 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -30,7 +30,6 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader"; import { PseudoRandom } from "./PseudoRandom"; import { ClientID, GameStartInfo, Turn } from "./Schemas"; import { simpleHash } from "./Util"; -import { censorNameWithClanTag } from "./validations/username"; export async function createGameRunner( gameStart: GameStartInfo, @@ -48,7 +47,7 @@ export async function createGameRunner( const humans = gameStart.players.map((p) => { return new PlayerInfo( - p.clientID === clientID ? p.username : censorNameWithClanTag(p.username), + p.username, PlayerType.Human, p.clientID, random.nextID(), diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 15ce0d564..65a5b74d8 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -603,12 +603,20 @@ export class GameView implements GameMap { private _config: Config, private _mapData: TerrainMapData, private _myClientID: ClientID, + private _myUsername: string, private _gameID: GameID, private humans: Player[], ) { this._map = this._mapData.gameMap; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); + // Replace the local player's username with their own stored username. + // This way the user does not know they are being censored. + for (const h of this.humans) { + if (h.clientID === this._myClientID) { + h.username = this._myUsername; + } + } this._cosmetics = new Map( this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]), ); diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index 8c348b31c..cb55390f2 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -1,92 +1,9 @@ -import { - RegExpMatcher, - collapseDuplicatesTransformer, - englishDataset, - englishRecommendedTransformers, - resolveConfusablesTransformer, - resolveLeetSpeakTransformer, - skipNonAlphabeticTransformer, -} from "obscenity"; import { translateText } from "../../client/Utils"; import { UsernameSchema } from "../Schemas"; -import { getClanTagOriginalCase, simpleHash } from "../Util"; - -const matcher = new RegExpMatcher({ - ...englishDataset.build(), - ...englishRecommendedTransformers, - ...resolveConfusablesTransformer(), - ...skipNonAlphabeticTransformer(), - ...collapseDuplicatesTransformer(), - ...resolveLeetSpeakTransformer(), -}); export const MIN_USERNAME_LENGTH = 3; export const MAX_USERNAME_LENGTH = 27; -const shadowNames = [ - "NicePeopleOnly", - "BeKindPlz", - "LearningManners", - "StayClassy", - "BeNicer", - "NeedHugs", - "MakeFriends", -]; - -export function fixProfaneUsername(username: string): string { - if (isProfaneUsername(username)) { - return shadowNames[simpleHash(username) % shadowNames.length]; - } - return username; -} - -export function isProfaneUsername(username: string): boolean { - return matcher.hasMatch(username); -} - -/** - * Sanitizes and censors profane usernames and clan tags. - * Profane username is overwritten, profane clan tag is removed. - * - * Preserves non-profane clan tag: - * prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten - * - * 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" - */ -export function censorNameWithClanTag(username: string): string { - // Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match - const clanTag = getClanTagOriginalCase(username); - - const nameWithoutClan = clanTag - ? username.replace(`[${clanTag}]`, "").trim() - : username; - - const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false; - const usernameIsProfane = isProfaneUsername(nameWithoutClan); - - const censoredNameWithoutClan = usernameIsProfane - ? fixProfaneUsername(nameWithoutClan) - : nameWithoutClan; - - // Restore clan tag if it existed and is not profane - if (clanTag && !clanTagIsProfane) { - return `[${clanTag.toUpperCase()}] ${censoredNameWithoutClan}`; - } - - // Don't restore profane or nonexistent clan tag - return censoredNameWithoutClan; -} - export function validateUsername(username: string): { isValid: boolean; error?: string; diff --git a/src/server/Client.ts b/src/server/Client.ts index 6f07b6562..9fda7317d 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -18,6 +18,7 @@ export class Client { public readonly flares: string[] | undefined, public readonly ip: string, public readonly username: string, + public readonly uncensoredUsername: string, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, ) {} diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 62d917fd3..c3d5af3e0 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -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); + } } diff --git a/src/server/PrivilegeRefresher.ts b/src/server/PrivilegeRefresher.ts index 030da9621..086c7218a 100644 --- a/src/server/PrivilegeRefresher.ts +++ b/src/server/PrivilegeRefresher.ts @@ -8,7 +8,7 @@ import { PrivilegeCheckerImpl, } from "./Privilege"; -// Refreshes the privilege checker every 5 minutes. +// Refreshes the privilege checker every 3 minutes. // WARNING: This fails open if cosmetics.json is not available. export class PrivilegeRefresher { private privilegeChecker: PrivilegeChecker | null = null; @@ -18,7 +18,9 @@ export class PrivilegeRefresher { private log: Logger; constructor( - private endpoint: string, + private cosmeticsEndpoint: string, + private profaneWordsEndpoint: string, + private apiKey: string, parentLog: Logger, private refreshInterval: number = 1000 * 60 * 3, ) { @@ -37,27 +39,62 @@ export class PrivilegeRefresher { } private async loadPrivilegeChecker(): Promise { - this.log.info(`Loading privilege checker from ${this.endpoint}`); + this.log.info(`Loading privilege checker`); try { - const response = await fetch(this.endpoint); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const fetchWithTimeout = async (url: string) => { + try { + return await fetch(url, { + signal: AbortSignal.timeout(5000), + headers: { "x-api-key": this.apiKey }, + }); + } catch (error) { + this.log.warn(`Failed to fetch ${url}: ${error}`); + return null; + } + }; + + const [cosmeticsResponse, profaneWordsResponse] = await Promise.all([ + fetchWithTimeout(this.cosmeticsEndpoint), + fetchWithTimeout(this.profaneWordsEndpoint), + ]); + + if (!cosmeticsResponse || !cosmeticsResponse.ok) { + throw new Error( + `Cosmetics HTTP error! status: ${cosmeticsResponse?.status ?? "network error"}`, + ); } - const cosmeticsData = await response.json(); + const cosmeticsData = await cosmeticsResponse.json(); const result = CosmeticsSchema.safeParse(cosmeticsData); if (!result.success) { throw new Error(`Invalid cosmetics data: ${result.error.message}`); } + let bannedWords: string[] = []; + if (profaneWordsResponse && profaneWordsResponse.ok) { + try { + bannedWords = await profaneWordsResponse.json(); + this.log.info( + `Loaded ${bannedWords.length} profane words from ${this.profaneWordsEndpoint}`, + ); + } catch (error) { + this.log.warn(`Failed to parse profane words JSON, using empty list`); + } + } else { + this.log.warn( + `Failed to fetch profane words (status ${profaneWordsResponse?.status ?? "network error"}), using empty list`, + ); + } + this.privilegeChecker = new PrivilegeCheckerImpl( result.data, base64url.decode, + bannedWords, ); this.log.info(`Privilege checker loaded successfully`); } catch (error) { - this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error); + this.log.error(`Failed to load privilege checker:`, error); throw error; } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 931f41369..a5bf11bed 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -67,6 +67,8 @@ export async function startWorker() { const privilegeRefresher = new PrivilegeRefresher( config.jwtIssuer() + "/cosmetics.json", + config.jwtIssuer() + "/profane_words_game_server", + config.apiKey(), log, ); privilegeRefresher.start(); @@ -436,6 +438,11 @@ export async function startWorker() { } } + // Censor profane usernames server-side (don't reject, just rename) + const censoredUsername = privilegeRefresher + .get() + .censorUsername(clientMsg.username); + // Create client and add to game const client = new Client( generateID(), @@ -444,6 +451,7 @@ export async function startWorker() { roles, flares, ip, + censoredUsername, clientMsg.username, ws, cosmeticResult.cosmetics, diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index 72e77daa2..4c5253d72 100644 --- a/tests/Censor.test.ts +++ b/tests/Censor.test.ts @@ -1,30 +1,3 @@ -// Mocking the obscenity library to control its behavior in tests. -vi.mock("obscenity", () => { - return { - RegExpMatcher: class { - private dummy: string[] = ["foo", "bar", "leet", "code"]; - constructor(_opts: any) {} - hasMatch(input: string): boolean { - const lower = input.toLowerCase(); - const decoded = lower - .replace(/4/g, "a") - .replace(/3/g, "e") - .replace(/1/g, "i") - .replace(/0/g, "o") - .replace(/5/g, "s") - .replace(/7/g, "t"); - return this.dummy.some((token) => decoded.includes(token)); - } - }, - collapseDuplicatesTransformer: () => ({}), - englishRecommendedTransformers: {}, - englishDataset: { build: () => ({}) }, - resolveConfusablesTransformer: () => ({}), - resolveLeetSpeakTransformer: () => ({}), - skipNonAlphabeticTransformer: () => ({}), - }; -}); - // Mocks the output of translation functions to return predictable values. vi.mock("../src/client/Utils", () => ({ translateText: (key: string, vars?: any) => @@ -32,53 +5,11 @@ vi.mock("../src/client/Utils", () => ({ })); import { - fixProfaneUsername, - isProfaneUsername, MAX_USERNAME_LENGTH, validateUsername, } from "../src/core/validations/username"; describe("username.ts functions", () => { - const shadowNames = [ - "NicePeopleOnly", - "BeKindPlz", - "LearningManners", - "StayClassy", - "BeNicer", - "NeedHugs", - "MakeFriends", - ]; - - describe("isProfaneUsername & fixProfaneUsername with leet decoding (mocked)", () => { - test.each([ - { username: "l33t", profane: true }, // decodes to "leet" - { username: "L33T", profane: true }, - { username: "l33tc0de", profane: true }, // decodes to "leetcode", contains "leet" and "code" - { username: "L33TC0DE", profane: true }, - { username: "foo123", profane: true }, // contains "foo" - { username: "b4r", profane: true }, // decodes to "bar" - { username: "safeName", profane: false }, - { username: "s4f3", profane: false }, // decodes to "safe" but "safe" not in dummy list - ])('isProfaneUsername("%s") → %s', ({ username, profane }) => { - expect(isProfaneUsername(username)).toBe(profane); - }); - - test.each([ - { username: "safeName" }, - { username: "l33t" }, - { username: "b4rUser" }, - ])('fixProfaneUsername("%s") behavior', ({ username }) => { - const profane = isProfaneUsername(username); - const fixed = fixProfaneUsername(username); - if (!profane) { - expect(fixed).toBe(username); - } else { - // When profane: result should be one of shadowNames - expect(shadowNames).toContain(fixed); - } - }); - }); - describe("validateUsername", () => { test("rejects non-string", () => { // @ts-expect-error: Testing non-string input to validateUsername on purpose diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts new file mode 100644 index 000000000..e3acc62b3 --- /dev/null +++ b/tests/Privilege.test.ts @@ -0,0 +1,147 @@ +import { + createMatcher, + PrivilegeCheckerImpl, + shadowNames, +} from "../src/server/Privilege"; + +const bannedWords = [ + "hitler", + "adolf", + "nazi", + "jew", + "auschwitz", + "whitepower", + "heil", + "chair", // Test word to verify custom banned words work +]; + +const matcher = createMatcher(bannedWords); + +// Create a minimal PrivilegeCheckerImpl for testing censorUsername +const mockCosmetics = { patterns: {}, colorPalettes: {} }; +const mockDecoder = () => new Uint8Array(); +const checker = new PrivilegeCheckerImpl( + mockCosmetics, + mockDecoder, + bannedWords, +); +const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []); + +describe("UsernameCensor", () => { + describe("isProfane (via matcher.hasMatch)", () => { + test("detects exact banned words", () => { + expect(matcher.hasMatch("hitler")).toBe(true); + expect(matcher.hasMatch("nazi")).toBe(true); + expect(matcher.hasMatch("auschwitz")).toBe(true); + }); + + test("detects custom banned words like 'chair'", () => { + expect(matcher.hasMatch("chair")).toBe(true); + expect(matcher.hasMatch("Chair")).toBe(true); + expect(matcher.hasMatch("CHAIR")).toBe(true); + expect(matcher.hasMatch("MyChairName")).toBe(true); + }); + + test("detects banned words case-insensitively", () => { + expect(matcher.hasMatch("Hitler")).toBe(true); + expect(matcher.hasMatch("NAZI")).toBe(true); + expect(matcher.hasMatch("Adolf")).toBe(true); + }); + + test("detects banned words with leet speak", () => { + expect(matcher.hasMatch("h1tl3r")).toBe(true); + expect(matcher.hasMatch("4d0lf")).toBe(true); + expect(matcher.hasMatch("n4z1")).toBe(true); + }); + + test("detects banned words with duplicated characters", () => { + expect(matcher.hasMatch("hiiitler")).toBe(true); + expect(matcher.hasMatch("naazzii")).toBe(true); + }); + + test("detects banned words with accented characters", () => { + expect(matcher.hasMatch("Adölf")).toBe(true); + }); + + test("detects banned words as substrings", () => { + expect(matcher.hasMatch("xhitlerx")).toBe(true); + expect(matcher.hasMatch("IloveNazi")).toBe(true); + }); + + test("allows clean usernames", () => { + expect(matcher.hasMatch("CoolPlayer")).toBe(false); + expect(matcher.hasMatch("GameMaster")).toBe(false); + expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false); + }); + }); + + describe("censorUsername", () => { + test("returns clean usernames unchanged", () => { + expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("GameMaster")).toBe("GameMaster"); + }); + + test("replaces profane usernames with a shadow name", () => { + const result = checker.censorUsername("hitler"); + expect(shadowNames).toContain(result); + }); + + test("replaces leet speak profane usernames with a shadow name", () => { + const result = checker.censorUsername("h1tl3r"); + expect(shadowNames).toContain(result); + }); + + test("preserves clean clan tag when username is profane", () => { + const result = checker.censorUsername("[COOL]hitler"); + expect(result).toMatch(/^\[COOL\] /); + const nameAfterTag = result.replace("[COOL] ", ""); + expect(shadowNames).toContain(nameAfterTag); + }); + + test("removes profane clan tag but keeps clean username", () => { + expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes clan tag with leet speak profanity", () => { + expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes clan tag with uppercased banned word", () => { + expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes clan tag containing banned word substring", () => { + expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer"); + }); + + test("removes profane clan tag and censors profane username", () => { + const result = checker.censorUsername("[NAZI]hitler"); + // No clan tag prefix, just a shadow name + expect(shadowNames).toContain(result); + }); + + test("removes leet speak profane clan tag and censors leet speak username", () => { + const result = checker.censorUsername("[N4Z1]h1tl3r"); + // No clan tag prefix, just a shadow name + expect(shadowNames).toContain(result); + }); + + test("returns deterministic shadow name for same input", () => { + const a = checker.censorUsername("hitler"); + const b = checker.censorUsername("hitler"); + expect(a).toBe(b); + }); + + test("handles username with no clan tag", () => { + expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer"); + }); + + test("empty banned words list still catches englishDataset profanity", () => { + // The emptyChecker still uses englishDataset, so common profanity is caught + expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); + // Verify a known english profanity gets censored even without custom banned words + const result = emptyChecker.censorUsername("fuck"); + expect(shadowNames).toContain(result); + }); + }); +});