diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index c31da062e..2dacd98da 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -90,14 +90,33 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher { skipNonAlphabeticTransformer(), ], }); + // A collapse-matcher hit is only a genuine elongation bypass when the matched + // text actually repeats a letter (e.g. "niiigger", "cooons", "nigg"). The + // deduped patterns collapse a banned word's natural double into a shorter, + // common substring ("coons" -> "cons", "boong" -> "bong", "nigger" -> "niger"), + // so without this guard the collapse matcher censors innocent names like + // "console", "Nigeria", or "busy". A canonical slur with no repeated letter is + // identical to its deduped pattern and is still caught by substringMatcher, so + // requiring a repeat only drops false positives, never real slurs. The span is + // widened by one char each side because a collapsed duplicate at the leading or + // trailing edge (e.g. "nigg" matching "nig") falls just outside the match. + const elongatedCollapseMatches = (input: string, sorted?: boolean) => + collapseMatcher.getAllMatches(input, sorted).filter((m) => + /(.)\1/.test( + input + .slice(Math.max(0, m.startIndex - 1), m.endIndex + 2) + .toLowerCase() + .replace(/[^a-z]/g, ""), + ), + ); return { hasMatch: (input: string) => input.toLowerCase().includes("kkk") || substringMatcher.hasMatch(input) || - collapseMatcher.hasMatch(input), + elongatedCollapseMatches(input).length > 0, getAllMatches: (input: string, sorted?: boolean) => [ ...substringMatcher.getAllMatches(input, sorted), - ...collapseMatcher.getAllMatches(input, sorted), + ...elongatedCollapseMatches(input, sorted), ], } as unknown as RegExpMatcher; } diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 5fc8e753d..8f33dc2cd 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -21,6 +21,12 @@ const bannedWords = [ "faggot", "retard", "chair", // Test word to verify custom banned words work + // Banned words with a natural double letter: the collapse matcher deduped + // these to common substrings ("coons" -> "cons", "boong" -> "bong") and used + // to censor innocent names. See the elongation regression tests below. + "coons", + "boong", + "bussy", ]; const matcher = createMatcher(bannedWords); @@ -165,6 +171,32 @@ describe("UsernameCensor", () => { expect(matcher.hasMatch("snigger")).toBe(false); }); + test("does not censor innocent words that collide with a deduped slur", () => { + // The collapse matcher deduped "coons" -> "cons", "boong" -> "bong", + // "bussy" -> "busy", "nigger" -> "niger". Without the repeated-letter + // guard, every word containing those substrings was censored. + expect(matcher.hasMatch("cons")).toBe(false); + expect(matcher.hasMatch("console")).toBe(false); + expect(matcher.hasMatch("icons")).toBe(false); + expect(matcher.hasMatch("constitution")).toBe(false); + expect(matcher.hasMatch("busy")).toBe(false); + expect(matcher.hasMatch("bong")).toBe(false); + expect(matcher.hasMatch("Bongo")).toBe(false); + expect(matcher.hasMatch("Nigeria")).toBe(false); + expect(matcher.hasMatch("Nigerian")).toBe(false); + }); + + test("still catches elongated slurs (collapse matcher)", () => { + // The repeated-letter guard must not weaken elongation detection: these + // all have a real repeated letter in the matched span. + expect(matcher.hasMatch("niiigger")).toBe(true); + expect(matcher.hasMatch("niiigger123")).toBe(true); + expect(matcher.hasMatch("cooons")).toBe(true); + expect(matcher.hasMatch("coons")).toBe(true); + expect(matcher.hasMatch("boooong")).toBe(true); + expect(matcher.hasMatch("bussy")).toBe(true); + }); + test("catches kkk as substring", () => { expect(matcher.hasMatch("kkk")).toBe(true); expect(matcher.hasMatch("KKK")).toBe(true);