From 1378eb5097b666a4add27923e47be73919957e5b Mon Sep 17 00:00:00 2001
From: Danny Avila <110412045+danny-avila@users.noreply.github.com>
Date: Mon, 18 Sep 2023 16:57:12 -0400
Subject: [PATCH] fix: Allow Latin-based Special Characters in Username (#969)
* fix: username validation
* fix: add data-testid to fix e2e workflow
---
api/strategies/validators.js | 23 ++-
api/strategies/validators.spec.js | 178 ++++++++++++++++++
.../src/components/Endpoints/MinimalIcon.tsx | 1 +
3 files changed, 194 insertions(+), 8 deletions(-)
diff --git a/api/strategies/validators.js b/api/strategies/validators.js
index e5c24c368..22e4fa6ec 100644
--- a/api/strategies/validators.js
+++ b/api/strategies/validators.js
@@ -11,6 +11,20 @@ function errorsToString(errors) {
.join(' ');
}
+const allowedCharactersRegex = /^[a-zA-Z0-9_.@#$%&*()\p{Script=Latin}\p{Script=Common}]+$/u;
+const injectionPatternsRegex = /('|--|\$ne|\$gt|\$lt|\$or|\{|\}|\*|;|<|>|\/|=)/i;
+
+const usernameSchema = z
+ .string()
+ .min(2)
+ .max(80)
+ .refine((value) => allowedCharactersRegex.test(value), {
+ message: 'Invalid characters in username',
+ })
+ .refine((value) => !injectionPatternsRegex.test(value), {
+ message: 'Potential injection attack detected',
+ });
+
const loginSchema = z.object({
email: z.string().email(),
password: z
@@ -26,14 +40,7 @@ const registerSchema = z
.object({
name: z.string().min(3).max(80),
username: z
- .union([
- z.literal(''),
- z
- .string()
- .min(2)
- .max(80)
- .regex(/^[a-zA-Z0-9_.-@#$%&*() ]+$/),
- ])
+ .union([z.literal(''), usernameSchema])
.transform((value) => (value === '' ? null : value))
.optional()
.nullable(),
diff --git a/api/strategies/validators.spec.js b/api/strategies/validators.spec.js
index 19bb9b8b7..bd4e2192f 100644
--- a/api/strategies/validators.spec.js
+++ b/api/strategies/validators.spec.js
@@ -260,6 +260,184 @@ describe('Zod Schemas', () => {
});
expect(result.success).toBe(true);
});
+
+ it('should handle username with special characters from various languages', () => {
+ const usernames = [
+ // General
+ 'éèäöü',
+
+ // German
+ 'Jöhn.Döe@',
+ 'Jöhn_Ü',
+ 'Jöhnß',
+
+ // French
+ 'Jéan-Piérre',
+ 'Élève',
+ 'Fiançée',
+ 'Mère',
+
+ // Spanish
+ 'Niño',
+ 'Señor',
+ 'Muñoz',
+
+ // Portuguese
+ 'João',
+ 'Coração',
+ 'Pão',
+
+ // Italian
+ 'Pietro',
+ 'Bambino',
+ 'Forlì',
+
+ // Romanian
+ 'Mâncare',
+ 'Școală',
+ 'Țară',
+
+ // Catalan
+ 'Niç',
+ 'Màquina',
+ 'Çap',
+
+ // Swedish
+ 'Fjärran',
+ 'Skål',
+ 'Öland',
+
+ // Norwegian
+ 'Blåbær',
+ 'Fjord',
+ 'Årstid',
+
+ // Danish
+ 'Flød',
+ 'Søster',
+ 'Århus',
+
+ // Icelandic
+ 'Þór',
+ 'Ætt',
+ 'Öx',
+
+ // Turkish
+ 'Şehir',
+ 'Çocuk',
+ 'Gözlük',
+
+ // Polish
+ 'Łódź',
+ 'Część',
+ 'Świat',
+
+ // Czech
+ 'Čaj',
+ 'Řeka',
+ 'Život',
+
+ // Slovak
+ 'Kočka',
+ 'Ľudia',
+ 'Žaba',
+
+ // Croatian
+ 'Čovjek',
+ 'Šuma',
+ 'Žaba',
+
+ // Hungarian
+ 'Tűz',
+ 'Ősz',
+ 'Ünnep',
+
+ // Finnish
+ 'Mäki',
+ 'Yö',
+ 'Äiti',
+
+ // Estonian
+ 'Tänav',
+ 'Öö',
+ 'Ülikool',
+
+ // Latvian
+ 'Ēka',
+ 'Ūdens',
+ 'Čempions',
+
+ // Lithuanian
+ 'Ūsas',
+ 'Ąžuolas',
+ 'Čia',
+
+ // Dutch
+ 'Maïs',
+ 'Geërfd',
+ 'Coördinatie',
+ ];
+
+ const failingUsernames = usernames.reduce((acc, username) => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username,
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ if (!result.success) {
+ acc.push({ username, error: result.error });
+ }
+
+ return acc;
+ }, []);
+
+ if (failingUsernames.length > 0) {
+ console.log('Failing Usernames:', failingUsernames);
+ }
+ expect(failingUsernames).toEqual([]);
+ });
+
+ it('should reject invalid usernames', () => {
+ const invalidUsernames = [
+ 'Дмитрий', // Cyrillic characters
+ 'محمد', // Arabic characters
+ '张伟', // Chinese characters
+ 'john{doe}', // Contains `{` and `}`
+ 'j', // Only one character
+ 'a'.repeat(81), // More than 80 characters
+ '\' OR \'1\'=\'1\'; --', // SQL Injection
+ '{$ne: null}', // MongoDB Injection
+ '', // Basic XSS
+ '">', // XSS breaking out of an attribute
+ '">
', // XSS using an image tag
+ ];
+
+ const passingUsernames = [];
+ const failingUsernames = invalidUsernames.reduce((acc, username) => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username,
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ if (!result.success) {
+ acc.push({ username, error: result.error });
+ }
+
+ if (result.success) {
+ passingUsernames.push({ username });
+ }
+
+ return acc;
+ }, []);
+
+ expect(failingUsernames.length).toEqual(invalidUsernames.length); // They should match since all invalidUsernames should fail.
+ });
});
describe('errorsToString', () => {
diff --git a/client/src/components/Endpoints/MinimalIcon.tsx b/client/src/components/Endpoints/MinimalIcon.tsx
index d35161e56..2d25c5fd2 100644
--- a/client/src/components/Endpoints/MinimalIcon.tsx
+++ b/client/src/components/Endpoints/MinimalIcon.tsx
@@ -35,6 +35,7 @@ const MinimalIcon: React.FC = (props) => {
return (