diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index ab06a48aa..6b8239f2f 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -124,13 +124,24 @@ function censorUsernameWithMatcher( ? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss" : false; const usernameIsProfane = matcher.hasMatch(nameWithoutClan); + // Catch slurs split across clan tag and username (e.g. "[HIT]LER", "[NIG]ger") + // by looking for a match that spans the clan/name boundary. + const combinedSlurAcrossBoundary = clanTag + ? matcher.getAllMatches(clanTag + nameWithoutClan).some( + (match) => + // Match must start in the clan and extend into the name — otherwise + // it's already handled by the clan-only or name-only checks above. + match.startIndex < clanTag.length && match.endIndex >= clanTag.length, + ) + : false; - const censoredName = usernameIsProfane - ? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length] - : nameWithoutClan; + const censoredName = + usernameIsProfane || combinedSlurAcrossBoundary + ? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length] + : nameWithoutClan; // Restore clan tag only if it's clean, otherwise remove it entirely - if (clanTag && !clanTagIsProfane) { + if (clanTag && !clanTagIsProfane && !combinedSlurAcrossBoundary) { return `[${clanTag.toUpperCase()}] ${censoredName}`; } diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index bedfc797b..3c9140dde 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -230,6 +230,44 @@ describe("UsernameCensor", () => { expect(shadowNames).toContain(result); expect(result).not.toContain("["); }); + + describe("clan tag + username combined forms a slur", () => { + test("censors when clan+name combined forms hitler", () => { + const result = checker.censorUsername("[HIT]LER"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + + test("censors when clan+name combined forms hitler (split differently)", () => { + const result = checker.censorUsername("[HI]TLER"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + + test("censors when clan+name combined forms adolf", () => { + const result = checker.censorUsername("[AD]OLF"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + + test("censors when clan+name combined forms nigger", () => { + const result = checker.censorUsername("[NIG]ger"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + + test("censors when clan+name combined forms nigger (clean parts)", () => { + const result = checker.censorUsername("[NI]gger"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + + test("censors leet speak combined across clan and name", () => { + const result = checker.censorUsername("[N1G]g3r"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + }); }); test("returns deterministic shadow name for same input", () => {