Skip to content

Commit e0a2ea5

Browse files
authored
Merge pull request #49 from Blaspsoft/fix/unicode-and-wildcard-bypasses
Fix: invisible unicode and asterisk censoring bypasses
2 parents a6758c9 + ea1de49 commit e0a2ea5

4 files changed

Lines changed: 123 additions & 26 deletions

File tree

config/blasp.php

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -156,32 +156,32 @@
156156
|--------------------------------------------------------------------------
157157
*/
158158
'substitutions' => [
159-
'/a/' => ['a', '4', '@', 'Á', 'á', 'À', 'Â', 'à', 'Â', 'â', 'Ä', 'ä', 'Ã', 'ã', 'Å', 'å', 'æ', 'Æ', 'α', 'Δ', 'Λ', 'λ'],
160-
'/b/' => ['b', '8', '\\', '3', 'ß', 'Β', 'β'],
161-
'/c/' => ['c', 'Ç', 'ç', 'ć', 'Ć', 'č', 'Č', '¢', '', '<', '(', '{', '©'],
162-
'/d/' => ['d', '\\', ')', 'Þ', 'þ', 'Ð', 'ð'],
163-
'/e/' => ['e', '3', '', 'È', 'è', 'É', 'é', 'Ê', 'ê', 'ë', 'Ë', 'ē', 'Ē', 'ė', 'Ė', 'ę', 'Ę', ''],
164-
'/f/' => ['f', 'ƒ'],
165-
'/g/' => ['g', '6', '9'],
166-
'/h/' => ['h', 'Η'],
167-
'/i/' => ['i', '!', '|', ']', '[', '1', '', 'Ì', 'Í', 'Î', 'Ï', 'ì', 'í', 'î', 'ï', 'ī', 'Ī', 'į', 'Į'],
168-
'/j/' => ['j'],
169-
'/k/' => ['k', 'Κ', 'κ'],
170-
'/l/' => ['l', '!', '|', ']', '[', '£', '', 'Ì', 'Í', 'Î', 'Ï', 'ł', 'Ł'],
171-
'/m/' => ['m'],
172-
'/n/' => ['n', 'η', 'Ν', 'Π', 'ñ', 'Ñ', 'ń', 'Ń'],
173-
'/o/' => ['o', '0', 'Ο', 'ο', 'Φ', '¤', '°', 'ø', 'ô', 'Ô', 'ö', 'Ö', 'ò', 'Ò', 'ó', 'Ó', 'œ', 'Œ', 'ø', 'Ø', 'ō', 'Ō', 'õ', 'Õ'],
174-
'/p/' => ['p', 'ρ', 'Ρ', '', 'þ'],
175-
'/q/' => ['q'],
176-
'/r/' => ['r', '®'],
177-
'/s/' => ['s', '5', '\$', '§', 'ß', 'Ś', 'ś', 'Š', 'š'],
178-
'/t/' => ['t', 'Τ', 'τ'],
159+
'/a/' => ['a', '4', '@', '*', 'Á', 'á', 'À', 'Â', 'à', 'Â', 'â', 'Ä', 'ä', 'Ã', 'ã', 'Å', 'å', 'æ', 'Æ', 'α', 'Δ', 'Λ', 'λ'],
160+
'/b/' => ['b', '8', '\\', '3', '*', 'ß', 'Β', 'β'],
161+
'/c/' => ['c', '*', 'Ç', 'ç', 'ć', 'Ć', 'č', 'Č', '¢', '', '<', '(', '{', '©'],
162+
'/d/' => ['d', '*', '\\', ')', 'Þ', 'þ', 'Ð', 'ð'],
163+
'/e/' => ['e', '3', '*', '', 'È', 'è', 'É', 'é', 'Ê', 'ê', 'ë', 'Ë', 'ē', 'Ē', 'ė', 'Ė', 'ę', 'Ę', ''],
164+
'/f/' => ['f', '*', 'ƒ'],
165+
'/g/' => ['g', '6', '9', '*'],
166+
'/h/' => ['h', '*', 'Η'],
167+
'/i/' => ['i', '!', '|', ']', '[', '1', '*', '', 'Ì', 'Í', 'Î', 'Ï', 'ì', 'í', 'î', 'ï', 'ī', 'Ī', 'į', 'Į'],
168+
'/j/' => ['j', '*'],
169+
'/k/' => ['k', '*', 'Κ', 'κ'],
170+
'/l/' => ['l', '!', '|', ']', '[', '*', '£', '', 'Ì', 'Í', 'Î', 'Ï', 'ł', 'Ł'],
171+
'/m/' => ['m', '*'],
172+
'/n/' => ['n', '*', 'η', 'Ν', 'Π', 'ñ', 'Ñ', 'ń', 'Ń'],
173+
'/o/' => ['o', '0', '*', 'Ο', 'ο', 'Φ', '¤', '°', 'ø', 'ô', 'Ô', 'ö', 'Ö', 'ò', 'Ò', 'ó', 'Ó', 'œ', 'Œ', 'ø', 'Ø', 'ō', 'Ō', 'õ', 'Õ'],
174+
'/p/' => ['p', '*', 'ρ', 'Ρ', '', 'þ'],
175+
'/q/' => ['q', '*'],
176+
'/r/' => ['r', '*', '®'],
177+
'/s/' => ['s', '5', '*', '\$', '§', 'ß', 'Ś', 'ś', 'Š', 'š'],
178+
'/t/' => ['t', '*', 'Τ', 'τ'],
179179
'/u/' => ['u', 'υ', 'µ', 'û', 'ü', 'ù', 'ú', 'ū', 'Û', 'Ü', 'Ù', 'Ú', 'Ū', '@', '*'],
180-
'/v/' => ['v', 'υ', 'ν'],
181-
'/w/' => ['w', 'ω', 'ψ', 'Ψ'],
182-
'/x/' => ['x', 'Χ', 'χ'],
183-
'/y/' => ['y', '¥', 'γ', 'ÿ', 'ý', 'Ÿ', 'Ý'],
184-
'/z/' => ['z', 'Ζ', 'ž', 'Ž', 'ź', 'Ź', 'ż', 'Ż'],
180+
'/v/' => ['v', '*', 'υ', 'ν'],
181+
'/w/' => ['w', '*', 'ω', 'ψ', 'Ψ'],
182+
'/x/' => ['x', '*', 'Χ', 'χ'],
183+
'/y/' => ['y', '*', '¥', 'γ', 'ÿ', 'ý', 'Ÿ', 'Ý'],
184+
'/z/' => ['z', '*', 'Ζ', 'ž', 'Ž', 'ź', 'Ź', 'ż', 'Ż'],
185185
],
186186

187187
/*

src/Core/Analyzer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ public function analyze(
1717
): Result {
1818
$mask = $mask ?? new CharacterMask(config('blasp.mask', config('blasp.mask_character', '*')));
1919

20+
// Strip invisible Unicode format characters (zero-width spaces, invisible separators, etc.)
21+
// before any driver sees the text, ensuring consistent positions across pipeline drivers
22+
$text = preg_replace('/\p{Cf}/u', '', $text) ?? $text;
23+
2024
return $driver->detect($text, $dictionary, $mask, $options);
2125
}
2226
}

src/Drivers/RegexDriver.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
105105
$continue = true;
106106

107107
// Mask in normalizedString only (needed for loop termination)
108-
$normalizedString = mb_substr($normalizedString, 0, $start) . str_repeat('*', $length) .
108+
// Use SOH control char internally to avoid re-matching when '*' is
109+
// a valid substitution character in profanity patterns
110+
$normalizedString = mb_substr($normalizedString, 0, $start) . str_repeat("\x01", $length) .
109111
mb_substr($normalizedString, $start + $length);
110112

111113
// Record masked range using character positions from immutable string

tests/BypassVulnerabilityTest.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
namespace Blaspsoft\Blasp\Tests;
4+
5+
use Blaspsoft\Blasp\Facades\Blasp;
6+
7+
class BypassVulnerabilityTest extends TestCase
8+
{
9+
// -------------------------------------------------------
10+
// Invisible Unicode Characters (U+2063, U+200B, etc.)
11+
// -------------------------------------------------------
12+
13+
public function test_invisible_separator_in_fuck()
14+
{
15+
$result = Blasp::check("f\u{2063}uck");
16+
$this->assertTrue($result->isOffensive());
17+
$this->assertContains('fuck', $result->uniqueWords());
18+
}
19+
20+
public function test_zero_width_space_in_shit()
21+
{
22+
$result = Blasp::check("s\u{200B}hit");
23+
$this->assertTrue($result->isOffensive());
24+
$this->assertContains('shit', $result->uniqueWords());
25+
}
26+
27+
public function test_multiple_invisible_chars_in_profanity()
28+
{
29+
$result = Blasp::check("f\u{200B}\u{2063}uck");
30+
$this->assertTrue($result->isOffensive());
31+
$this->assertContains('fuck', $result->uniqueWords());
32+
}
33+
34+
public function test_invisible_chars_in_clean_text_no_false_positive()
35+
{
36+
$result = Blasp::check("he\u{2063}llo");
37+
$this->assertFalse($result->isOffensive());
38+
}
39+
40+
public function test_invisible_separator_clean_output_masks_profanity()
41+
{
42+
$result = Blasp::check("f\u{2063}uck this");
43+
$this->assertTrue($result->isOffensive());
44+
$this->assertSame('**** this', $result->clean());
45+
}
46+
47+
// -------------------------------------------------------
48+
// Censored Profanity (asterisk as letter replacement)
49+
// -------------------------------------------------------
50+
51+
public function test_asterisk_censored_fag()
52+
{
53+
$result = Blasp::check('f*g');
54+
$this->assertTrue($result->isOffensive());
55+
$this->assertContains('fag', $result->uniqueWords());
56+
}
57+
58+
public function test_asterisk_censored_fuck()
59+
{
60+
$result = Blasp::check('f**k');
61+
$this->assertTrue($result->isOffensive());
62+
}
63+
64+
public function test_asterisk_censored_shit()
65+
{
66+
$result = Blasp::check('s**t');
67+
$this->assertTrue($result->isOffensive());
68+
}
69+
70+
public function test_asterisk_fully_censored_fuck()
71+
{
72+
$result = Blasp::check('f***');
73+
$this->assertTrue($result->isOffensive());
74+
}
75+
76+
public function test_asterisk_in_non_profane_word_no_false_positive()
77+
{
78+
$result = Blasp::check('b*g');
79+
$this->assertFalse($result->isOffensive());
80+
}
81+
82+
// -------------------------------------------------------
83+
// Combined: invisible + wildcard
84+
// -------------------------------------------------------
85+
86+
public function test_invisible_char_plus_asterisk_censoring()
87+
{
88+
$result = Blasp::check("f\u{2063}*g");
89+
$this->assertTrue($result->isOffensive());
90+
}
91+
}

0 commit comments

Comments
 (0)