From 69737542ea9d10e848ddbd374834aafc6808bc60 Mon Sep 17 00:00:00 2001 From: Andrew Niziolek Date: Tue, 17 Jun 2025 01:10:26 -0400 Subject: [PATCH] REV - Improved Username Censoring (#1119) ## Description: The present implementation of the obscenity library leaves some transformers un-used or in improper order damaging the overall effectiveness of the implementation. ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: ajaxburger --- src/core/validations/username.ts | 15 +++- tests/Censor.test.ts | 134 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/Censor.test.ts diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index 92f6baa13..7cc6c03d1 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -1,7 +1,11 @@ import { RegExpMatcher, + collapseDuplicatesTransformer, englishDataset, englishRecommendedTransformers, + resolveConfusablesTransformer, + resolveLeetSpeakTransformer, + skipNonAlphabeticTransformer, } from "obscenity"; import { translateText } from "../../client/Utils"; import { simpleHash } from "../Util"; @@ -9,6 +13,10 @@ import { simpleHash } from "../Util"; const matcher = new RegExpMatcher({ ...englishDataset.build(), ...englishRecommendedTransformers, + ...resolveConfusablesTransformer(), + ...skipNonAlphabeticTransformer(), + ...collapseDuplicatesTransformer(), + ...resolveLeetSpeakTransformer(), }); export const MIN_USERNAME_LENGTH = 3; @@ -34,7 +42,7 @@ export function fixProfaneUsername(username: string): string { } export function isProfaneUsername(username: string): boolean { - return matcher.hasMatch(username) || username.toLowerCase().includes("nig"); + return matcher.hasMatch(username); } export function validateUsername(username: string): { @@ -77,8 +85,9 @@ export function validateUsername(username: string): { } export function sanitizeUsername(str: string): string { - const sanitized = str - .replace(/[^a-zA-Z0-9_\[\] 🐈🍀]/gu, "") + const sanitized = Array.from(str) + .filter((ch) => validPattern.test(ch)) + .join("") .slice(0, MAX_USERNAME_LENGTH); return sanitized.padEnd(MIN_USERNAME_LENGTH, "x"); } diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts new file mode 100644 index 000000000..74f9cccc2 --- /dev/null +++ b/tests/Censor.test.ts @@ -0,0 +1,134 @@ +// Mocking the obscenity library to control its behavior in tests. +jest.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. +jest.mock("../src/client/Utils", () => ({ + translateText: (key: string, vars?: any) => + vars ? `${key}:${JSON.stringify(vars)}` : key, +})); + +import { + fixProfaneUsername, + isProfaneUsername, + MAX_USERNAME_LENGTH, + MIN_USERNAME_LENGTH, + sanitizeUsername, + 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 + const res = validateUsername(123); + expect(res.isValid).toBe(false); + expect(res.error).toBeDefined(); + }); + test("rejects too short", () => { + const res = validateUsername("ab"); + expect(res.isValid).toBe(false); + }); + test("rejects too long", () => { + const long = "a".repeat(MAX_USERNAME_LENGTH + 1); + const res = validateUsername(long); + expect(res.isValid).toBe(false); + }); + test("rejects invalid chars", () => { + const res = validateUsername("Invalid!Name"); + expect(res.isValid).toBe(false); + }); + test("accepts valid ASCII names", () => { + const res = validateUsername("Good_Name123"); + expect(res.isValid).toBe(true); + }); + test("accepts allowed Unicode like 🐈 or ü", () => { + const res = validateUsername("Cat🐈Üser"); + expect(res.isValid).toBe(true); + }); + }); + + describe("sanitizeUsername", () => { + test.each([ + { input: "GoodName", expected: "GoodName" }, + { input: "a!", expected: "axx" }, + { input: "a$%b", expected: "abx" }, + { + input: "abc".repeat(10), + expected: "abc" + .repeat(Math.floor(MAX_USERNAME_LENGTH / 3)) + .slice(0, MAX_USERNAME_LENGTH), + }, + { input: "", expected: "xxx" }, + { input: "Ünicode🐈Test!", expected: "Ünicode🐈Test" }, + ])('sanitizeUsername("%s") → "%s"', ({ input, expected }) => { + const out = sanitizeUsername(input); + expect(out).toBe(expected); + expect(out.length).toBeGreaterThanOrEqual(MIN_USERNAME_LENGTH); + expect(out.length).toBeLessThanOrEqual(MAX_USERNAME_LENGTH); + }); + }); +});