diff --git a/src/Appwrite/Filter/Name.php b/src/Appwrite/Filter/Name.php
index 0ee9e5e674..0dd962169d 100644
--- a/src/Appwrite/Filter/Name.php
+++ b/src/Appwrite/Filter/Name.php
@@ -4,33 +4,6 @@ namespace Appwrite\Filter;
class Name implements Filter
{
- /**
- * Homoglyph mapping: visually similar characters mapped to their Latin equivalents.
- */
- private const HOMOGLYPHS = [
- // Cyrillic
- 'а' => 'a', 'е' => 'e', 'о' => 'o', 'р' => 'p', 'с' => 'c',
- 'у' => 'y', 'х' => 'x', 'А' => 'A', 'В' => 'B', 'Е' => 'E',
- 'К' => 'K', 'М' => 'M', 'Н' => 'H', 'О' => 'O', 'Р' => 'P',
- 'С' => 'C', 'Т' => 'T', 'У' => 'Y', 'Х' => 'X',
- // Greek
- 'α' => 'a', 'ο' => 'o', 'ε' => 'e', 'Α' => 'A', 'Β' => 'B',
- 'Ε' => 'E', 'Η' => 'H', 'Ι' => 'I', 'Κ' => 'K', 'Μ' => 'M',
- 'Ν' => 'N', 'Ο' => 'O', 'Ρ' => 'P', 'Τ' => 'T', 'Χ' => 'X',
- 'Ζ' => 'Z', 'ν' => 'v', 'τ' => 't',
- ];
-
- /**
- * Scam trigger phrases to block (checked against lowercased input).
- */
- private const SCAM_PHRASES = [
- 'giveaway', 'free crypto', 'click here', 'contact me on',
- 'send me dm', 'discount', 'promo code', 'limited offer',
- 'whatsapp', 'telegram', 'discord', 'free money', 'act now',
- 'congratulations you won', 'claim your prize', 'send me a message',
- 'dm me', 'text me', 'call me', 'wire transfer',
- ];
-
private const MAX_LENGTH = 32;
public function apply(mixed $input): mixed
@@ -39,183 +12,30 @@ class Name implements Filter
return $input;
}
- $input = $this->stripZeroWidthChars($input);
- $input = $this->normalizeUnicode($input);
- $input = $this->normalizeHomoglyphs($input);
- $input = $this->removeEmojis($input);
- $input = $this->removeUrls($input);
- $input = $this->removeSocialHandles($input);
- $input = $this->stripPhoneNumbers($input);
- $input = $this->removeEmailAddresses($input);
- $input = $this->stripHtmlTags($input);
- $input = $this->blockScamPhrases($input);
- $input = $this->collapseWhitespace($input);
- $input = $this->cutToMaxLength($input);
+ // Remove HTML tags
+ $input = \strip_tags($input);
- return $input;
- }
+ $words = \explode(' ', $input);
+
+ // Remove emails
+ $words = \array_filter($words, fn (string $word) => !\str_contains($word, '@') || !\str_contains($word, '.'));
+
+ // Remove URLs
+ $words = \array_filter($words, fn (string $word) => !\str_starts_with($word, '://') && !\str_starts_with(\strtolower($word), 'www.'));
+
+ // Remove phone numbers
+ $words = \array_filter($words, function (string $word) {
+ $digitCount = 0;
+ for ($i = 0, $len = \strlen($word); $i < $len; $i++) {
+ if (\ctype_digit($word[$i])) {
+ $digitCount++;
+ }
+ }
+ return $digitCount < 7;
+ });
+
+ $input = \implode(' ', $words);
- /**
- * Cut to max length.
- */
- private function cutToMaxLength(string $input): string
- {
return \mb_substr($input, 0, self::MAX_LENGTH);
}
-
- /**
- * Remove emoji characters.
- */
- private function removeEmojis(string $input): string
- {
- // Covers most emoji ranges including emoticons, symbols, dingbats, flags, etc.
- $pattern = '/[\x{1F600}-\x{1F64F}' . // Emoticons
- '\x{1F300}-\x{1F5FF}' . // Misc symbols & pictographs
- '\x{1F680}-\x{1F6FF}' . // Transport & map
- '\x{1F1E0}-\x{1F1FF}' . // Flags
- '\x{2600}-\x{26FF}' . // Misc symbols
- '\x{2700}-\x{27BF}' . // Dingbats
- '\x{FE00}-\x{FE0F}' . // Variation selectors
- '\x{1F900}-\x{1F9FF}' . // Supplemental symbols
- '\x{1FA00}-\x{1FA6F}' . // Chess symbols
- '\x{1FA70}-\x{1FAFF}' . // Symbols extended-A
- '\x{200D}' . // Zero-width joiner (used in emoji sequences)
- '\x{20E3}' . // Combining enclosing keycap
- ']+/u';
-
- return \preg_replace($pattern, '', $input) ?? $input;
- }
-
- /**
- * Remove URLs (http://, https://, www., and common TLD patterns).
- */
- private function removeUrls(string $input): string
- {
- // Match http(s):// URLs
- $input = \preg_replace('#https?://[^\s]+#iu', '', $input) ?? $input;
-
- // Match www. URLs
- $input = \preg_replace('#www\.[^\s]+#iu', '', $input) ?? $input;
-
- // Match bare domain-like patterns (e.g. "example.com", "scam.link")
- $input = \preg_replace('#\b[a-zA-Z0-9\-]+\.(com|org|net|io|co|me|info|link|xyz|click|top|ru|cn|tk|ml|ga|cf|gq|pw|bid|win)\b#iu', '', $input) ?? $input;
-
- return $input;
- }
-
- /**
- * Remove social media handles (@username, #hashtag).
- */
- private function removeSocialHandles(string $input): string
- {
- // Remove @mentions
- $input = \preg_replace('/@[\w.]{1,30}/u', '', $input) ?? $input;
-
- // Remove #hashtags
- $input = \preg_replace('/#[\w]{1,50}/u', '', $input) ?? $input;
-
- return $input;
- }
-
- /**
- * Strip phone numbers and phone-like patterns.
- */
- private function stripPhoneNumbers(string $input): string
- {
- // Match international format: +1 234 567 8900, +44-20-7946-0958, etc.
- $input = \preg_replace('/\+?\d[\d\s\-\.\(\)]{6,}\d/u', '', $input) ?? $input;
-
- return $input;
- }
-
- /**
- * Remove email addresses.
- */
- private function removeEmailAddresses(string $input): string
- {
- $input = \preg_replace('/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/u', '', $input) ?? $input;
-
- return $input;
- }
-
- /**
- * Strip HTML tags and elements.
- */
- private function stripHtmlTags(string $input): string
- {
- return \strip_tags($input);
- }
-
- /**
- * Strip zero-width and invisible Unicode characters.
- */
- private function stripZeroWidthChars(string $input): string
- {
- $pattern = '/[\x{200B}-\x{200F}' . // Zero-width space, non-joiner, joiner, LTR/RTL marks
- '\x{2028}-\x{202F}' . // Line/paragraph separators, embedding controls
- '\x{2060}-\x{2064}' . // Word joiner, invisible separators
- '\x{FEFF}' . // BOM / zero-width no-break space
- '\x{00AD}' . // Soft hyphen
- '\x{034F}' . // Combining grapheme joiner
- '\x{061C}' . // Arabic letter mark
- '\x{180E}' . // Mongolian vowel separator
- ']+/u';
-
- return \preg_replace($pattern, '', $input) ?? $input;
- }
-
- /**
- * Normalize Unicode using NFKC normalization.
- * Converts compatibility characters to their canonical equivalents.
- */
- private function normalizeUnicode(string $input): string
- {
- if (\function_exists('normalizer_normalize')) {
- return \Normalizer::normalize($input, \Normalizer::FORM_KC) ?: $input;
- }
-
- return $input;
- }
-
- /**
- * Normalize homoglyph/lookalike characters to Latin equivalents.
- */
- private function normalizeHomoglyphs(string $input): string
- {
- return \str_replace(
- \array_keys(self::HOMOGLYPHS),
- \array_values(self::HOMOGLYPHS),
- $input
- );
- }
-
- /**
- * Block known scam trigger phrases by removing them.
- */
- private function blockScamPhrases(string $input): string
- {
- $lower = \mb_strtolower($input);
-
- foreach (self::SCAM_PHRASES as $phrase) {
- $pos = \mb_strpos($lower, $phrase);
- while ($pos !== false) {
- $len = \mb_strlen($phrase);
- $input = \mb_substr($input, 0, $pos) . \mb_substr($input, $pos + $len);
- $lower = \mb_substr($lower, 0, $pos) . \mb_substr($lower, $pos + $len);
- $pos = \mb_strpos($lower, $phrase, $pos);
- }
- }
-
- return $input;
- }
-
- /**
- * Collapse repeated whitespace and trim.
- */
- private function collapseWhitespace(string $input): string
- {
- $input = \preg_replace('/\s+/u', ' ', $input) ?? $input;
-
- return \trim($input);
- }
}
diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php
index a81da60968..7e4aaa5fc5 100644
--- a/tests/e2e/Services/Account/AccountBase.php
+++ b/tests/e2e/Services/Account/AccountBase.php
@@ -142,6 +142,48 @@ trait AccountBase
];
}
+ public function testCreateAccountNameFilter(): void
+ {
+ $headers = array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ]);
+
+ // Name with email gets stripped
+ $response = $this->client->call(Client::METHOD_POST, '/account', $headers, [
+ 'userId' => ID::unique(),
+ 'email' => uniqid() . 'filter1@localhost.test',
+ 'password' => 'password',
+ 'name' => 'John user@example.com',
+ ]);
+
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $this->assertEquals('John', $response['body']['name']);
+
+ // Name with phone number gets stripped
+ $response = $this->client->call(Client::METHOD_POST, '/account', $headers, [
+ 'userId' => ID::unique(),
+ 'email' => uniqid() . 'filter2@localhost.test',
+ 'password' => 'password',
+ 'name' => 'John +1234567890',
+ ]);
+
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $this->assertEquals('John', $response['body']['name']);
+
+ // Name with URL gets stripped
+ $response = $this->client->call(Client::METHOD_POST, '/account', $headers, [
+ 'userId' => ID::unique(),
+ 'email' => uniqid() . 'filter3@localhost.test',
+ 'password' => 'password',
+ 'name' => 'John https://example.com',
+ ]);
+
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $this->assertEquals('John', $response['body']['name']);
+ }
+
public function testEmailOTPSession(): void
{
$isConsoleProject = $this->getProject()['$id'] === 'console';
diff --git a/tests/unit/Filter/NameTest.php b/tests/unit/Filter/NameTest.php
new file mode 100644
index 0000000000..26e33a406a
--- /dev/null
+++ b/tests/unit/Filter/NameTest.php
@@ -0,0 +1,78 @@
+filter = new Name();
+ }
+
+ public function testNonStringInput(): void
+ {
+ $this->assertSame(123, $this->filter->apply(123));
+ $this->assertSame(null, $this->filter->apply(null));
+ $this->assertSame(true, $this->filter->apply(true));
+ $this->assertSame([], $this->filter->apply([]));
+ }
+
+ public function testPlainName(): void
+ {
+ $this->assertSame('John Doe', $this->filter->apply('John Doe'));
+ }
+
+ public function testHtmlTags(): void
+ {
+ $this->assertSame('John Doe', $this->filter->apply('John Doe'));
+ $this->assertSame('Hello', $this->filter->apply('Hello'));
+ }
+
+ public function testEmails(): void
+ {
+ $this->assertSame('John', $this->filter->apply('John user@example.com'));
+ $this->assertSame('John Doe', $this->filter->apply('John test@mail.org Doe'));
+ }
+
+ public function testUrls(): void
+ {
+ $this->assertSame('John', $this->filter->apply('John http://example.com'));
+ $this->assertSame('John', $this->filter->apply('John https://example.com'));
+ $this->assertSame('Visit', $this->filter->apply('Visit www.example.com'));
+ $this->assertSame('Visit', $this->filter->apply('Visit WWW.EXAMPLE.COM'));
+ }
+
+ public function testPhoneNumbers(): void
+ {
+ $this->assertSame('John', $this->filter->apply('John 1234567'));
+ $this->assertSame('John', $this->filter->apply('John +1-234-567-8901'));
+ $this->assertSame('Call', $this->filter->apply('Call 555-123-4567'));
+ }
+
+ public function testShortNumbersKept(): void
+ {
+ $this->assertSame('Agent 007', $this->filter->apply('Agent 007'));
+ $this->assertSame('Room 404', $this->filter->apply('Room 404'));
+ }
+
+ public function testMaxLength(): void
+ {
+ $long = str_repeat('A', 50);
+ $this->assertSame(str_repeat('A', 32), $this->filter->apply($long));
+ }
+
+ public function testCombined(): void
+ {
+ $this->assertSame('John', $this->filter->apply('John user@example.com https://evil.com +1234567890'));
+ }
+
+ public function testEmptyString(): void
+ {
+ $this->assertSame('', $this->filter->apply(''));
+ }
+}