From e4f4b4d3109e3214bad07f7c17512542d7cb5a72 Mon Sep 17 00:00:00 2001 From: Evan Pelle Date: Sat, 27 Jun 2026 16:57:56 +0000 Subject: [PATCH] fix: stop name censor flagging innocent words like "cons" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapse matcher (which catches elongation bypasses like "niiigger") pre-collapses banned words by reducing every run of a repeated letter to one. That turns short slurs with a natural double letter into common substrings — "coons" -> "cons", "boong" -> "bong", "bussy" -> "busy", "nigger" -> "niger" — and since it substring-matches, those censor innocent names like console, icons, busy, Bongo, Nigeria, and negative. Against the live banned list this falsely censored ~half of ordinary test names. A collapse hit is only a genuine elongation bypass when the matched text actually repeats a letter. Filter collapse matches to keep only those whose span (widened one char each side, to catch a trailing collapsed duplicate such as "nigg" -> "nig") contains a repeated letter. This can only remove collapse matches, never add them: a canonically-spelled slur with no repeat equals its deduped pattern and is still caught by the literal substring matcher, so no real slur is lost. Co-Authored-By: Claude Opus 4.8 --- src/server/Privilege.ts | 23 +++++++++++++++++++++-- tests/Privilege.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) 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);