Skip to content

Commit 74ebed6

Browse files
authored
fix: Allow Latin-based Special Characters in Username (danny-avila#969)
* fix: username validation * fix: add data-testid to fix e2e workflow
1 parent 31cdd15 commit 74ebed6

File tree

3 files changed

+194
-8
lines changed

3 files changed

+194
-8
lines changed

api/strategies/validators.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ function errorsToString(errors) {
1111
.join(' ');
1212
}
1313

14+
const allowedCharactersRegex = /^[a-zA-Z0-9_.@#$%&*()\p{Script=Latin}\p{Script=Common}]+$/u;
15+
const injectionPatternsRegex = /('|--|\$ne|\$gt|\$lt|\$or|\{|\}|\*|;|<|>|\/|=)/i;
16+
17+
const usernameSchema = z
18+
.string()
19+
.min(2)
20+
.max(80)
21+
.refine((value) => allowedCharactersRegex.test(value), {
22+
message: 'Invalid characters in username',
23+
})
24+
.refine((value) => !injectionPatternsRegex.test(value), {
25+
message: 'Potential injection attack detected',
26+
});
27+
1428
const loginSchema = z.object({
1529
email: z.string().email(),
1630
password: z
@@ -26,14 +40,7 @@ const registerSchema = z
2640
.object({
2741
name: z.string().min(3).max(80),
2842
username: z
29-
.union([
30-
z.literal(''),
31-
z
32-
.string()
33-
.min(2)
34-
.max(80)
35-
.regex(/^[a-zA-Z0-9_.-@#$%&*() ]+$/),
36-
])
43+
.union([z.literal(''), usernameSchema])
3744
.transform((value) => (value === '' ? null : value))
3845
.optional()
3946
.nullable(),

api/strategies/validators.spec.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,184 @@ describe('Zod Schemas', () => {
260260
});
261261
expect(result.success).toBe(true);
262262
});
263+
264+
it('should handle username with special characters from various languages', () => {
265+
const usernames = [
266+
// General
267+
'éèäöü',
268+
269+
// German
270+
'Jöhn.Döe@',
271+
'Jöhn_Ü',
272+
'Jöhnß',
273+
274+
// French
275+
'Jéan-Piérre',
276+
'Élève',
277+
'Fiançée',
278+
'Mère',
279+
280+
// Spanish
281+
'Niño',
282+
'Señor',
283+
'Muñoz',
284+
285+
// Portuguese
286+
'João',
287+
'Coração',
288+
'Pão',
289+
290+
// Italian
291+
'Pietro',
292+
'Bambino',
293+
'Forlì',
294+
295+
// Romanian
296+
'Mâncare',
297+
'Școală',
298+
'Țară',
299+
300+
// Catalan
301+
'Niç',
302+
'Màquina',
303+
'Çap',
304+
305+
// Swedish
306+
'Fjärran',
307+
'Skål',
308+
'Öland',
309+
310+
// Norwegian
311+
'Blåbær',
312+
'Fjord',
313+
'Årstid',
314+
315+
// Danish
316+
'Flød',
317+
'Søster',
318+
'Århus',
319+
320+
// Icelandic
321+
'Þór',
322+
'Ætt',
323+
'Öx',
324+
325+
// Turkish
326+
'Şehir',
327+
'Çocuk',
328+
'Gözlük',
329+
330+
// Polish
331+
'Łódź',
332+
'Część',
333+
'Świat',
334+
335+
// Czech
336+
'Čaj',
337+
'Řeka',
338+
'Život',
339+
340+
// Slovak
341+
'Kočka',
342+
'Ľudia',
343+
'Žaba',
344+
345+
// Croatian
346+
'Čovjek',
347+
'Šuma',
348+
'Žaba',
349+
350+
// Hungarian
351+
'Tűz',
352+
'Ősz',
353+
'Ünnep',
354+
355+
// Finnish
356+
'Mäki',
357+
'Yö',
358+
'Äiti',
359+
360+
// Estonian
361+
'Tänav',
362+
'Öö',
363+
'Ülikool',
364+
365+
// Latvian
366+
'Ēka',
367+
'Ūdens',
368+
'Čempions',
369+
370+
// Lithuanian
371+
'Ūsas',
372+
'Ąžuolas',
373+
'Čia',
374+
375+
// Dutch
376+
'Maïs',
377+
'Geërfd',
378+
'Coördinatie',
379+
];
380+
381+
const failingUsernames = usernames.reduce((acc, username) => {
382+
const result = registerSchema.safeParse({
383+
name: 'John Doe',
384+
username,
385+
386+
password: 'password123',
387+
confirm_password: 'password123',
388+
});
389+
390+
if (!result.success) {
391+
acc.push({ username, error: result.error });
392+
}
393+
394+
return acc;
395+
}, []);
396+
397+
if (failingUsernames.length > 0) {
398+
console.log('Failing Usernames:', failingUsernames);
399+
}
400+
expect(failingUsernames).toEqual([]);
401+
});
402+
403+
it('should reject invalid usernames', () => {
404+
const invalidUsernames = [
405+
'Дмитрий', // Cyrillic characters
406+
'محمد', // Arabic characters
407+
'张伟', // Chinese characters
408+
'john{doe}', // Contains `{` and `}`
409+
'j', // Only one character
410+
'a'.repeat(81), // More than 80 characters
411+
'\' OR \'1\'=\'1\'; --', // SQL Injection
412+
'{$ne: null}', // MongoDB Injection
413+
'<script>alert("XSS")</script>', // Basic XSS
414+
'"><script>alert("XSS")</script>', // XSS breaking out of an attribute
415+
'"><img src=x onerror=alert("XSS")>', // XSS using an image tag
416+
];
417+
418+
const passingUsernames = [];
419+
const failingUsernames = invalidUsernames.reduce((acc, username) => {
420+
const result = registerSchema.safeParse({
421+
name: 'John Doe',
422+
username,
423+
424+
password: 'password123',
425+
confirm_password: 'password123',
426+
});
427+
428+
if (!result.success) {
429+
acc.push({ username, error: result.error });
430+
}
431+
432+
if (result.success) {
433+
passingUsernames.push({ username });
434+
}
435+
436+
return acc;
437+
}, []);
438+
439+
expect(failingUsernames.length).toEqual(invalidUsernames.length); // They should match since all invalidUsernames should fail.
440+
});
263441
});
264442

265443
describe('errorsToString', () => {

client/src/components/Endpoints/MinimalIcon.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
3535

3636
return (
3737
<div
38+
data-testid="convo-icon"
3839
title={name}
3940
style={{
4041
width: size,

0 commit comments

Comments
 (0)