From cf9e370160f0da05b0bba9e896c7689402837bac Mon Sep 17 00:00:00 2001 From: Tajim Date: Sun, 30 Aug 2026 23:34:12 +0600 Subject: [PATCH 1/5] fix(filter): prevent false positives on substrings like assalamualaikum while retaining obfuscation detection --- app/Services/ChatProfanityFilter.php | 25 ++++++++++++++++--------- tests/Feature/ChatAutoBanTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app/Services/ChatProfanityFilter.php b/app/Services/ChatProfanityFilter.php index f93d054a..948b5dc1 100644 --- a/app/Services/ChatProfanityFilter.php +++ b/app/Services/ChatProfanityFilter.php @@ -24,23 +24,30 @@ public static function hasProfanity(string $text): bool $normalized = self::normalize($text); + // 1. Remove punctuation separators within words (e.g. "f.u.c.k", "s_e_x", "a-s-s") + $strippedPunctuation = preg_replace('/(?<=\p{L})[._\-*~]+(?=\p{L})/u', '', $normalized); + + // 2. Collapse spaced-out single letters (e.g. "f u c k" -> "fuck", "a s s" -> "ass") + $collapsedSpaces = preg_replace_callback('/\b(?:\p{L}\s+){2,}\p{L}\b/u', function ($m) { + return preg_replace('/\s+/u', '', $m[0]); + }, $strippedPunctuation); + + $variants = [$text, $normalized, $strippedPunctuation, $collapsedSpaces]; + foreach ($bannedWords as $word) { $word = trim(mb_strtolower($word)); if ($word === '') { continue; } - // Exact word boundary matching or normalized substring matching $escaped = preg_quote($word, '/'); - if (preg_match("/\b{$escaped}\b/ui", $text) || preg_match("/\b{$escaped}\b/ui", $normalized)) { - return true; - } + // Unicode-safe word boundary pattern to prevent substring false-positives (e.g. "ass" in "assalamualaikum") + $pattern = '/(?<=^|[^\p{L}\p{N}])'.$escaped.'(?=[^\p{L}\p{N}]|$)/ui'; - // Compact match without spaces or special symbols (e.g. f.u.c.k or f_u_c_k) - $compactText = preg_replace('/[^\p{L}\p{N}]/u', '', $normalized); - $compactWord = preg_replace('/[^\p{L}\p{N}]/u', '', $word); - if ($compactWord !== '' && str_contains($compactText, $compactWord)) { - return true; + foreach ($variants as $variant) { + if (preg_match($pattern, $variant)) { + return true; + } } } diff --git a/tests/Feature/ChatAutoBanTest.php b/tests/Feature/ChatAutoBanTest.php index d72deffe..3d265c8e 100644 --- a/tests/Feature/ChatAutoBanTest.php +++ b/tests/Feature/ChatAutoBanTest.php @@ -2,6 +2,7 @@ use App\Models\AppSetting; use App\Models\User; +use App\Services\ChatProfanityFilter; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; @@ -110,3 +111,29 @@ expect($targetUser->fresh()->isChatBanned())->toBeFalse(); }); + +test('profanity filter allows legitimate words like assalamualaikum and class while blocking actual profanity and obfuscations', function () { + AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); + AppSetting::set('global_chat_banned_words', 'ass, fuck, sex, bitch, চোদা'); + + // Legitimate words containing substrings must NOT be blocked + expect(ChatProfanityFilter::hasProfanity('assalamualaikum'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('assalamualaikum brothers'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('walaikumsalam'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('class assignment'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('compass assistant'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('classic physics'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('password passage'))->toBeFalse(); + + // Actual profanity and obfuscations MUST be blocked + expect(ChatProfanityFilter::hasProfanity('you are an ass'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are an a.s.s'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are an a s s'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are an @$$'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('f.u.c.k you'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('f u c k you'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('s_e_x'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('s3x'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('5ex'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('তুই একটা চোদা'))->toBeTrue(); +}); From 171ca54963dadf242457faa244821320bd1b1eb0 Mon Sep 17 00:00:00 2001 From: Tajim Date: Sun, 30 Aug 2026 23:36:10 +0600 Subject: [PATCH 2/5] feat(chat): mask profanity with asterisks instead of blocking messages --- app/Http/Controllers/ChatController.php | 8 +--- app/Services/ChatProfanityFilter.php | 50 +++++++++++++++++++++++++ tests/Feature/ChatAutoBanTest.php | 23 ++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 336fc5c2..26b9c563 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -174,12 +174,8 @@ public function store(Request $request) $content = trim($validated['content']); - // Check for abusive / prohibited language - if (ChatProfanityFilter::hasProfanity($content)) { - return response()->json([ - 'message' => 'Your message contains inappropriate or prohibited language. If you try again, you may be temporarily banned from chat.', - ], 422); - } + // Mask/Censor abusive or prohibited language with asterisks (e.g. ****) + $content = ChatProfanityFilter::maskProfanity($content); // Prevent duplicate message sent twice in a streak by the same user $lastMessage = ChatMessage::where('user_id', $user->id) diff --git a/app/Services/ChatProfanityFilter.php b/app/Services/ChatProfanityFilter.php index 948b5dc1..fe1c557c 100644 --- a/app/Services/ChatProfanityFilter.php +++ b/app/Services/ChatProfanityFilter.php @@ -54,6 +54,56 @@ public static function hasProfanity(string $text): bool return false; } + /** + * Mask/Censor banned or abusive words with asterisks (e.g. ****). + */ + public static function maskProfanity(string $text, string $replacementChar = '*'): string + { + $enabled = AppSetting::get('global_chat_profanity_filter_enabled', true); + if (! $enabled) { + return $text; + } + + $bannedWords = self::getBannedWords(); + if (empty($bannedWords)) { + return $text; + } + + $substitutions = [ + '@' => 'a', '4' => 'a', '$' => 's', '5' => 's', + '1' => 'i', '!' => 'i', '|' => 'i', '0' => 'o', + '3' => 'e', '8' => 'b', '+' => 't', '7' => 't', + ]; + + foreach ($bannedWords as $word) { + $word = trim($word); + if ($word === '') { + continue; + } + + $chars = preg_split('//u', mb_strtolower($word), -1, PREG_SPLIT_NO_EMPTY); + $charPatterns = []; + foreach ($chars as $c) { + $equiv = [preg_quote($c, '/')]; + foreach ($substitutions as $sym => $target) { + if ($target === $c) { + $equiv[] = preg_quote($sym, '/'); + } + } + $charPatterns[] = '(?:'.implode('|', array_unique($equiv)).')+'; + } + + $wordRegex = implode('[._\-*~\s]*', $charPatterns); + $pattern = '/(?<=^|[^\p{L}\p{N}])('.$wordRegex.')(?=[^\p{L}\p{N}]|$)/ui'; + + $text = (string) preg_replace_callback($pattern, function ($m) use ($replacementChar) { + return str_repeat($replacementChar, mb_strlen($m[1])); + }, $text); + } + + return $text; + } + /** * Normalize text by replacing leet-speak substitutions and collapsing repeated characters. */ diff --git a/tests/Feature/ChatAutoBanTest.php b/tests/Feature/ChatAutoBanTest.php index 3d265c8e..d8147c26 100644 --- a/tests/Feature/ChatAutoBanTest.php +++ b/tests/Feature/ChatAutoBanTest.php @@ -137,3 +137,26 @@ expect(ChatProfanityFilter::hasProfanity('5ex'))->toBeTrue(); expect(ChatProfanityFilter::hasProfanity('তুই একটা চোদা'))->toBeTrue(); }); + +test('chat messages mask profanity with asterisks instead of blocking the user', function () { + AppSetting::set('global_chat_enabled', true, 'boolean'); + AppSetting::set('global_chat_audience', 'all'); + AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); + AppSetting::set('global_chat_banned_words', 'fuck, bitch, shit'); + + expect(ChatProfanityFilter::maskProfanity('Hello assalamualaikum brother'))->toBe('Hello assalamualaikum brother'); + expect(ChatProfanityFilter::maskProfanity('What the fuck is this shit'))->toBe('What the **** is this ****'); + expect(ChatProfanityFilter::maskProfanity('f.u.c.k you'))->toBe('******* you'); + + $user = User::factory()->create(['username' => 'chat_sender']); + + $response = $this->actingAs($user)->postJson(route('chat.messages.store'), [ + 'content' => 'What the fuck is this', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('chat_messages', [ + 'user_id' => $user->id, + 'content' => 'What the **** is this', + ]); +}); From 0a3bc9cffb46d7b030e40a20ced79b91e0c1732d Mon Sep 17 00:00:00 2001 From: Tajim Date: Sun, 30 Aug 2026 23:40:57 +0600 Subject: [PATCH 3/5] feat(chat): replace profane messages with system notice --- app/Services/ChatProfanityFilter.php | 46 +++------------------------- tests/Feature/ChatAutoBanTest.php | 8 ++--- 2 files changed, 8 insertions(+), 46 deletions(-) diff --git a/app/Services/ChatProfanityFilter.php b/app/Services/ChatProfanityFilter.php index fe1c557c..009f1db4 100644 --- a/app/Services/ChatProfanityFilter.php +++ b/app/Services/ChatProfanityFilter.php @@ -55,50 +55,12 @@ public static function hasProfanity(string $text): bool } /** - * Mask/Censor banned or abusive words with asterisks (e.g. ****). + * Mask/Censor message with a system notice if abusive or prohibited language is detected. */ - public static function maskProfanity(string $text, string $replacementChar = '*'): string + public static function maskProfanity(string $text, string $notice = '[Message hidden for inappropriate language]'): string { - $enabled = AppSetting::get('global_chat_profanity_filter_enabled', true); - if (! $enabled) { - return $text; - } - - $bannedWords = self::getBannedWords(); - if (empty($bannedWords)) { - return $text; - } - - $substitutions = [ - '@' => 'a', '4' => 'a', '$' => 's', '5' => 's', - '1' => 'i', '!' => 'i', '|' => 'i', '0' => 'o', - '3' => 'e', '8' => 'b', '+' => 't', '7' => 't', - ]; - - foreach ($bannedWords as $word) { - $word = trim($word); - if ($word === '') { - continue; - } - - $chars = preg_split('//u', mb_strtolower($word), -1, PREG_SPLIT_NO_EMPTY); - $charPatterns = []; - foreach ($chars as $c) { - $equiv = [preg_quote($c, '/')]; - foreach ($substitutions as $sym => $target) { - if ($target === $c) { - $equiv[] = preg_quote($sym, '/'); - } - } - $charPatterns[] = '(?:'.implode('|', array_unique($equiv)).')+'; - } - - $wordRegex = implode('[._\-*~\s]*', $charPatterns); - $pattern = '/(?<=^|[^\p{L}\p{N}])('.$wordRegex.')(?=[^\p{L}\p{N}]|$)/ui'; - - $text = (string) preg_replace_callback($pattern, function ($m) use ($replacementChar) { - return str_repeat($replacementChar, mb_strlen($m[1])); - }, $text); + if (self::hasProfanity($text)) { + return $notice; } return $text; diff --git a/tests/Feature/ChatAutoBanTest.php b/tests/Feature/ChatAutoBanTest.php index d8147c26..8cccc305 100644 --- a/tests/Feature/ChatAutoBanTest.php +++ b/tests/Feature/ChatAutoBanTest.php @@ -138,15 +138,15 @@ expect(ChatProfanityFilter::hasProfanity('তুই একটা চোদা'))->toBeTrue(); }); -test('chat messages mask profanity with asterisks instead of blocking the user', function () { +test('chat messages mask profanity with system notice instead of blocking the user', function () { AppSetting::set('global_chat_enabled', true, 'boolean'); AppSetting::set('global_chat_audience', 'all'); AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); AppSetting::set('global_chat_banned_words', 'fuck, bitch, shit'); expect(ChatProfanityFilter::maskProfanity('Hello assalamualaikum brother'))->toBe('Hello assalamualaikum brother'); - expect(ChatProfanityFilter::maskProfanity('What the fuck is this shit'))->toBe('What the **** is this ****'); - expect(ChatProfanityFilter::maskProfanity('f.u.c.k you'))->toBe('******* you'); + expect(ChatProfanityFilter::maskProfanity('What the fuck is this shit'))->toBe('[Message hidden for inappropriate language]'); + expect(ChatProfanityFilter::maskProfanity('f.u.c.k you'))->toBe('[Message hidden for inappropriate language]'); $user = User::factory()->create(['username' => 'chat_sender']); @@ -157,6 +157,6 @@ $response->assertStatus(201); $this->assertDatabaseHas('chat_messages', [ 'user_id' => $user->id, - 'content' => 'What the **** is this', + 'content' => '[Message hidden for inappropriate language]', ]); }); From 7595786418a4dfce9d05946297df7737ecdfeb9f Mon Sep 17 00:00:00 2001 From: Tajim Date: Sun, 30 Aug 2026 23:43:12 +0600 Subject: [PATCH 4/5] feat(chat): flag profane message with system notice directly in controller --- app/Http/Controllers/ChatController.php | 6 ++++-- app/Services/ChatProfanityFilter.php | 12 ------------ tests/Feature/ChatAutoBanTest.php | 6 +----- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 26b9c563..37f94afc 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -174,8 +174,10 @@ public function store(Request $request) $content = trim($validated['content']); - // Mask/Censor abusive or prohibited language with asterisks (e.g. ****) - $content = ChatProfanityFilter::maskProfanity($content); + // Check for abusive / prohibited language + if (ChatProfanityFilter::hasProfanity($content)) { + $content = '[Message hidden for inappropriate language]'; + } // Prevent duplicate message sent twice in a streak by the same user $lastMessage = ChatMessage::where('user_id', $user->id) diff --git a/app/Services/ChatProfanityFilter.php b/app/Services/ChatProfanityFilter.php index 009f1db4..948b5dc1 100644 --- a/app/Services/ChatProfanityFilter.php +++ b/app/Services/ChatProfanityFilter.php @@ -54,18 +54,6 @@ public static function hasProfanity(string $text): bool return false; } - /** - * Mask/Censor message with a system notice if abusive or prohibited language is detected. - */ - public static function maskProfanity(string $text, string $notice = '[Message hidden for inappropriate language]'): string - { - if (self::hasProfanity($text)) { - return $notice; - } - - return $text; - } - /** * Normalize text by replacing leet-speak substitutions and collapsing repeated characters. */ diff --git a/tests/Feature/ChatAutoBanTest.php b/tests/Feature/ChatAutoBanTest.php index 8cccc305..4d9458ea 100644 --- a/tests/Feature/ChatAutoBanTest.php +++ b/tests/Feature/ChatAutoBanTest.php @@ -138,16 +138,12 @@ expect(ChatProfanityFilter::hasProfanity('তুই একটা চোদা'))->toBeTrue(); }); -test('chat messages mask profanity with system notice instead of blocking the user', function () { +test('chat messages replace profanity with system notice instead of blocking the user', function () { AppSetting::set('global_chat_enabled', true, 'boolean'); AppSetting::set('global_chat_audience', 'all'); AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); AppSetting::set('global_chat_banned_words', 'fuck, bitch, shit'); - expect(ChatProfanityFilter::maskProfanity('Hello assalamualaikum brother'))->toBe('Hello assalamualaikum brother'); - expect(ChatProfanityFilter::maskProfanity('What the fuck is this shit'))->toBe('[Message hidden for inappropriate language]'); - expect(ChatProfanityFilter::maskProfanity('f.u.c.k you'))->toBe('[Message hidden for inappropriate language]'); - $user = User::factory()->create(['username' => 'chat_sender']); $response = $this->actingAs($user)->postJson(route('chat.messages.store'), [ From 385151e5b0c75cb096bc4200acf3ba0e7d88f59c Mon Sep 17 00:00:00 2001 From: Tajim Date: Sun, 30 Aug 2026 23:45:07 +0600 Subject: [PATCH 5/5] test: use clean placeholder terms in profanity tests --- tests/Feature/ChatAutoBanTest.php | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/Feature/ChatAutoBanTest.php b/tests/Feature/ChatAutoBanTest.php index 4d9458ea..90404341 100644 --- a/tests/Feature/ChatAutoBanTest.php +++ b/tests/Feature/ChatAutoBanTest.php @@ -112,9 +112,9 @@ expect($targetUser->fresh()->isChatBanned())->toBeFalse(); }); -test('profanity filter allows legitimate words like assalamualaikum and class while blocking actual profanity and obfuscations', function () { +test('profanity filter allows legitimate words containing substrings while blocking actual banned words and obfuscations', function () { AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); - AppSetting::set('global_chat_banned_words', 'ass, fuck, sex, bitch, চোদা'); + AppSetting::set('global_chat_banned_words', 'badword, toxic, spammer'); // Legitimate words containing substrings must NOT be blocked expect(ChatProfanityFilter::hasProfanity('assalamualaikum'))->toBeFalse(); @@ -125,29 +125,28 @@ expect(ChatProfanityFilter::hasProfanity('classic physics'))->toBeFalse(); expect(ChatProfanityFilter::hasProfanity('password passage'))->toBeFalse(); - // Actual profanity and obfuscations MUST be blocked - expect(ChatProfanityFilter::hasProfanity('you are an ass'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('you are an a.s.s'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('you are an a s s'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('you are an @$$'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('f.u.c.k you'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('f u c k you'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('s_e_x'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('s3x'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('5ex'))->toBeTrue(); - expect(ChatProfanityFilter::hasProfanity('তুই একটা চোদা'))->toBeTrue(); + // Actual banned words and obfuscations MUST be blocked + expect(ChatProfanityFilter::hasProfanity('you are badword'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are b.a.d.w.o.r.d'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are b a d w o r d'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are b@dw0rd'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t.o.x.i.c user'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t o x i c user'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t_o_x_i_c'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t0x1c'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('tooooxic'))->toBeTrue(); }); test('chat messages replace profanity with system notice instead of blocking the user', function () { AppSetting::set('global_chat_enabled', true, 'boolean'); AppSetting::set('global_chat_audience', 'all'); AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); - AppSetting::set('global_chat_banned_words', 'fuck, bitch, shit'); + AppSetting::set('global_chat_banned_words', 'badword, toxic'); $user = User::factory()->create(['username' => 'chat_sender']); $response = $this->actingAs($user)->postJson(route('chat.messages.store'), [ - 'content' => 'What the fuck is this', + 'content' => 'What is this badword', ]); $response->assertStatus(201);