mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:00:44 +00:00
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
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user