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
+1
View File
@@ -227,6 +227,7 @@ async function createClientGame(
config,
gameMap,
clientID,
lobbyConfig.playerName,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
);
+1 -2
View File
@@ -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(),
+8
View File
@@ -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 ?? {}]),
);
-83
View File
@@ -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;
+1
View File
@@ -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,
) {}
+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);
}
}
+45 -8
View File
@@ -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<void> {
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;
}
}
+8
View File
@@ -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,
-69
View File
@@ -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
+147
View File
@@ -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);
});
});
});