+
+ ${this.rarity !== "common"
+ ? html`
+
+
+
+ ${Array.from(
+ { length: 40 },
+ (_, i) =>
+ html``,
+ )}`
+ : null}
`;
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts
index 586a44055..bd808cdcc 100644
--- a/src/core/CosmeticSchemas.ts
+++ b/src/core/CosmeticSchemas.ts
@@ -51,8 +51,17 @@ export const ColorPaletteSchema = z.object({
secondaryColor: z.string(),
});
-export const PatternSchema = z.object({
+const CosmeticSchema = z.object({
name: CosmeticNameSchema,
+ affiliateCode: z.string().nullable(),
+ product: ProductSchema.nullable(),
+ artist: z.string().optional(),
+ rarity: z
+ .enum(["common", "uncommon", "rare", "epic", "legendary"])
+ .or(z.string()),
+});
+
+export const PatternSchema = CosmeticSchema.extend({
pattern: PatternDataSchema,
colorPalettes: z
.object({
@@ -61,17 +70,10 @@ export const PatternSchema = z.object({
})
.array()
.optional(),
- affiliateCode: z.string().nullable(),
- product: ProductSchema.nullable(),
- artist: z.string().optional(),
});
-export const FlagSchema = z.object({
- name: CosmeticNameSchema,
+export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
- affiliateCode: z.string().nullable(),
- product: ProductSchema.nullable(),
- artist: z.string().optional(),
});
// Schema for resources/cosmetics/cosmetics.json
diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts
index 5980c93d7..544a9761b 100644
--- a/src/server/Privilege.ts
+++ b/src/server/Privilege.ts
@@ -6,7 +6,6 @@ import {
pattern,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
- skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from "obscenity";
import countries from "resources/countries.json";
@@ -47,31 +46,52 @@ export const shadowNames = [
"AlmostPottyTrained",
];
-export function createMatcher(bannedWords: string[]): RegExpMatcher {
- const customDataset = new DataSet<{ originalWord: string }>().addAll(
+function buildDataset(bannedWords: string[], dedup: boolean) {
+ const dataset = new DataSet<{ originalWord: string }>().addAll(
englishDataset,
);
-
for (const word of bannedWords) {
try {
- customDataset.addPhrase((phrase) =>
- phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
+ const w = dedup ? word.toLowerCase().replace(/(.)\1+/g, "$1") : word;
+ dataset.addPhrase((phrase) =>
+ phrase.setMetadata({ originalWord: word }).addPattern(pattern`${w}`),
);
} catch (e) {
console.error(`Invalid banned word pattern "${word}": ${e}`);
}
}
+ return dataset.build();
+}
- return new RegExpMatcher({
- ...customDataset.build(),
+export function createMatcher(bannedWords: string[]): RegExpMatcher {
+ const baseTransformers = [
+ toAsciiLowerCaseTransformer(),
+ resolveConfusablesTransformer(),
+ resolveLeetSpeakTransformer(),
+ ];
+ // substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
+ // collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
+ const substringMatcher = new RegExpMatcher({
+ ...buildDataset(bannedWords, false),
+ blacklistMatcherTransformers: baseTransformers,
+ });
+ const collapseMatcher = new RegExpMatcher({
+ ...buildDataset(bannedWords, true),
blacklistMatcherTransformers: [
- toAsciiLowerCaseTransformer(),
- resolveConfusablesTransformer(),
- resolveLeetSpeakTransformer(),
+ ...baseTransformers,
collapseDuplicatesTransformer(),
- skipNonAlphabeticTransformer(),
],
});
+ return {
+ hasMatch: (input: string) =>
+ input.toLowerCase().includes("kkk") ||
+ substringMatcher.hasMatch(input) ||
+ collapseMatcher.hasMatch(input),
+ getAllMatches: (input: string, sorted?: boolean) => [
+ ...substringMatcher.getAllMatches(input, sorted),
+ ...collapseMatcher.getAllMatches(input, sorted),
+ ],
+ } as unknown as RegExpMatcher;
}
/**
@@ -100,9 +120,19 @@ function censorWithMatcher(
? shadowNames[simpleHash(username) % shadowNames.length]
: username;
- const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
- const censoredClanTag =
- clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null;
+ const clanTagIsProfane = clanTag
+ ? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss"
+ : 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 { username: censoredName, clanTag: censoredClanTag };
}
@@ -242,8 +272,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
-// Default matcher with no custom banned words (just englishDataset)
-const defaultMatcher = createMatcher([]);
+// Words the englishDataset misses or only catches as standalone tokens.
+// These are always enforced even when the remote banned-words list is unavailable.
+const baselineBannedWords = ["nigger", "nigga", "chink", "spic", "kike"];
+
+const defaultMatcher = createMatcher(baselineBannedWords);
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts
index 7c25f1ca3..5802a98e0 100644
--- a/tests/Privilege.test.ts
+++ b/tests/Privilege.test.ts
@@ -12,6 +12,13 @@ const bannedWords = [
"auschwitz",
"whitepower",
"heil",
+ "nigger",
+ "nigga",
+ "chink",
+ "spic",
+ "kike",
+ "faggot",
+ "retard",
"chair", // Test word to verify custom banned words work
];
@@ -36,6 +43,7 @@ const flagCosmetics = {
url: "https://example.com/cool.png",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
+ rarity: "common",
},
},
};
@@ -51,45 +59,84 @@ describe("UsernameCensor", () => {
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);
+ expect(matcher.hasMatch("nigger")).toBe(true);
+ expect(matcher.hasMatch("nigga")).toBe(true);
+ expect(matcher.hasMatch("chink")).toBe(true);
+ expect(matcher.hasMatch("spic")).toBe(true);
+ expect(matcher.hasMatch("kike")).toBe(true);
+ expect(matcher.hasMatch("faggot")).toBe(true);
+ expect(matcher.hasMatch("retard")).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);
+ expect(matcher.hasMatch("NIGGER")).toBe(true);
+ expect(matcher.hasMatch("Nigga")).toBe(true);
+ expect(matcher.hasMatch("FAGGOT")).toBe(true);
+ expect(matcher.hasMatch("Retard")).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);
+ expect(matcher.hasMatch("n1gg3r")).toBe(true);
+ expect(matcher.hasMatch("f4gg0t")).toBe(true);
+ expect(matcher.hasMatch("r3t4rd")).toBe(true);
});
test("detects banned words with duplicated characters", () => {
expect(matcher.hasMatch("hiiitler")).toBe(true);
expect(matcher.hasMatch("naazzii")).toBe(true);
+ expect(matcher.hasMatch("niiiigger")).toBe(true);
+ expect(matcher.hasMatch("faaggot")).toBe(true);
});
- test("detects banned words with accented characters", () => {
+ test("detects banned words with accented/confusable characters", () => {
expect(matcher.hasMatch("Adölf")).toBe(true);
+ expect(matcher.hasMatch("nïgger")).toBe(true);
});
test("detects banned words as substrings", () => {
expect(matcher.hasMatch("xhitlerx")).toBe(true);
expect(matcher.hasMatch("IloveNazi")).toBe(true);
+ // Regression: slur + suffix / prefix must be caught
+ expect(matcher.hasMatch("niggertesting")).toBe(true);
+ expect(matcher.hasMatch("testingnigger")).toBe(true);
+ expect(matcher.hasMatch("xnazix")).toBe(true);
+ expect(matcher.hasMatch("faggotry")).toBe(true);
+ expect(matcher.hasMatch("retarded")).toBe(true);
+ expect(matcher.hasMatch("MyChairName")).toBe(true);
+ });
+
+ test("detects banned words with underscores/dots/numbers mixed in", () => {
+ // These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
+ // Words separated by non-alpha chars are treated as separate tokens
+ expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
+ expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
});
test("allows clean usernames", () => {
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
expect(matcher.hasMatch("GameMaster")).toBe(false);
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
+ expect(matcher.hasMatch("ProGamer123")).toBe(false);
+ expect(matcher.hasMatch("NightOwl")).toBe(false);
+ expect(matcher.hasMatch("DragonSlayer")).toBe(false);
+ });
+
+ test("does not false-positive on words containing banned substrings legitimately", () => {
+ // "snigger" is whitelisted in englishDataset
+ expect(matcher.hasMatch("snigger")).toBe(false);
+ });
+
+ test("catches kkk as substring", () => {
+ expect(matcher.hasMatch("kkk")).toBe(true);
+ expect(matcher.hasMatch("KKK")).toBe(true);
+ expect(matcher.hasMatch("kkklover")).toBe(true);
+ expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
});
});
@@ -115,40 +162,73 @@ describe("UsernameCensor", () => {
expect(shadowNames).toContain(result.username);
});
- test("removes profane clan tag but keeps clean username", () => {
- const result = checker.censor("CoolPlayer", "NAZI");
- expect(result.username).toBe("CoolPlayer");
- expect(result.clanTag).toBeNull();
- });
+ describe("clan tag censoring", () => {
+ test("removes profane clan tag, keeps clean username", () => {
+ expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
+ expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
+ expect(checker.censorUsername("[HEIL]CoolPlayer")).toBe("CoolPlayer");
+ });
- test("removes clan tag with leet speak profanity", () => {
- const result = checker.censor("CoolPlayer", "N4Z1");
- expect(result.username).toBe("CoolPlayer");
- expect(result.clanTag).toBeNull();
- });
+ test("removes clan tag that is a slur abbreviation", () => {
+ // [NIG] is caught as a standalone word by englishDataset's |nig| pattern
+ expect(checker.censorUsername("[NIG]CoolPlayer")).toBe("CoolPlayer");
+ expect(checker.censorUsername("[NIGG]CoolPlayer")).toBe("CoolPlayer");
+ });
- test("removes clan tag with uppercased banned word", () => {
- const result = checker.censor("CoolPlayer", "ADOLF");
- expect(result.username).toBe("CoolPlayer");
- expect(result.clanTag).toBeNull();
- });
+ test("removes clan tag containing full slur (≤5 chars)", () => {
+ // Clan tags are capped at 5 chars — only slurs that fit are catchable this way
+ expect(checker.censorUsername("[NIGGA]CoolPlayer")).toBe("CoolPlayer");
+ expect(checker.censorUsername("[CHINK]CoolPlayer")).toBe("CoolPlayer");
+ expect(checker.censorUsername("[SPIC]CoolPlayer")).toBe("CoolPlayer");
+ expect(checker.censorUsername("[KIKE]CoolPlayer")).toBe("CoolPlayer");
+ });
- test("removes clan tag containing banned word substring", () => {
- const result = checker.censor("CoolPlayer", "JEWS");
- expect(result.username).toBe("CoolPlayer");
- expect(result.clanTag).toBeNull();
- });
+ test("removes clan tag with leet speak profanity (≤5 chars)", () => {
+ expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
+ });
- test("removes profane clan tag and censors profane username", () => {
- const result = checker.censor("hitler", "NAZI");
- expect(result.clanTag).toBeNull();
- expect(shadowNames).toContain(result.username);
- });
+ test("removes clan tag containing banned word as substring (≤5 chars)", () => {
+ expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
+ expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
+ });
- test("removes leet speak profane clan tag and censors leet speak username", () => {
- const result = checker.censor("h1tl3r", "N4Z1");
- expect(result.clanTag).toBeNull();
- expect(shadowNames).toContain(result.username);
+ test("removes [SS] clan tag", () => {
+ expect(checker.censorUsername("[SS]Player")).toBe("Player");
+ expect(checker.censorUsername("[ss]Player")).toBe("Player");
+ });
+
+ test("removes [KKK] clan tag", () => {
+ expect(checker.censorUsername("[KKK]Player")).toBe("Player");
+ });
+
+ test("keeps clean clan tag when username is clean", () => {
+ expect(checker.censorUsername("[COOL]Player")).toBe("[COOL] Player");
+ expect(checker.censorUsername("[PRO]Player")).toBe("[PRO] Player");
+ });
+
+ test("keeps clean clan tag, censors profane username", () => {
+ const result = checker.censorUsername("[COOL]nigger");
+ expect(result).toMatch(/^\[COOL\] /);
+ expect(shadowNames).toContain(result.replace("[COOL] ", ""));
+ });
+
+ test("removes profane clan tag and censors profane username", () => {
+ const result = checker.censorUsername("[NAZI]hitler");
+ expect(shadowNames).toContain(result);
+ expect(result).not.toContain("[");
+ });
+
+ test("removes profane clan tag and censors leet speak username", () => {
+ const result = checker.censorUsername("[N4Z1]h1tl3r");
+ expect(shadowNames).toContain(result);
+ expect(result).not.toContain("[");
+ });
+
+ test("removes profane clan tag with slur, censors profane username", () => {
+ const result = checker.censorUsername("[NIG]nigger");
+ expect(shadowNames).toContain(result);
+ expect(result).not.toContain("[");
+ });
});
test("returns deterministic shadow name for same input", () => {