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 (