From 7703140b7c309480d2d16f1016f045887aec6f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20U=C5=9Fakl=C4=B1?= Date: Sun, 8 Feb 2026 13:11:40 -0500 Subject: [PATCH] Xregexp remove, dont allow invalid slugs (#13963) * feat: remove xregexp add slugify tests, dont accept invalid slugs like `.`, `..` add isSlugValid function * test: add more tests, check slug on rename as well --- install/package.json | 1 - public/src/client/register.js | 11 +-- public/src/modules/slugify.js | 16 ++-- public/src/utils.common.js | 7 ++ src/user/create.js | 2 +- src/user/profile.js | 2 +- test/slugify.js | 164 ++++++++++++++++++++++++++++++++++ test/user.js | 17 ++++ test/utils.js | 11 --- 9 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 test/slugify.js diff --git a/install/package.json b/install/package.json index e14d370a1a..f8b6380ade 100644 --- a/install/package.json +++ b/install/package.json @@ -156,7 +156,6 @@ "winston": "3.19.0", "workerpool": "10.0.1", "xml": "1.0.1", - "xregexp": "5.1.2", "yargs": "17.7.2", "zxcvbn": "4.4.2" }, diff --git a/public/src/client/register.js b/public/src/client/register.js index f989901e7b..1cd0570ded 100644 --- a/public/src/client/register.js +++ b/public/src/client/register.js @@ -121,12 +121,13 @@ define('forum/register', [ username_notify.text(''); const usernameInput = $('#username'); const userslug = slugify(username); - if (username.length < ajaxify.data.minimumUsernameLength || userslug.length < ajaxify.data.minimumUsernameLength) { - showError(usernameInput, username_notify, '[[error:username-too-short]]'); - } else if (username.length > ajaxify.data.maximumUsernameLength) { - showError(usernameInput, username_notify, '[[error:username-too-long]]'); - } else if (!utils.isUserNameValid(username) || !userslug) { + const { minimumUsernameLength, maximumUsernameLength } = ajaxify.data; + if (!utils.isUserNameValid(username) || !utils.isSlugValid(userslug)) { showError(usernameInput, username_notify, '[[error:invalid-username]]'); + } else if (username.length < minimumUsernameLength || userslug.length < minimumUsernameLength) { + showError(usernameInput, username_notify, '[[error:username-too-short]]'); + } else if (username.length > maximumUsernameLength) { + showError(usernameInput, username_notify, '[[error:username-too-long]]'); } else { Promise.allSettled([ api.head(`/users/bySlug/${userslug}`, {}), diff --git a/public/src/modules/slugify.js b/public/src/modules/slugify.js index 159b356e47..7912d2576c 100644 --- a/public/src/modules/slugify.js +++ b/public/src/modules/slugify.js @@ -1,16 +1,15 @@ 'use strict'; -/* global XRegExp */ (function (factory) { if (typeof define === 'function' && define.amd) { - define('slugify', ['xregexp'], factory); + define('slugify', factory); } else if (typeof exports === 'object') { - module.exports = factory(require('xregexp')); + module.exports = factory(); } else { - window.slugify = factory(XRegExp); + window.slugify = factory(); } -}(function (XRegExp) { - const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_@.]', 'g'); +}(function () { + const invalidUnicodeChars = /[^\p{L}\s\d\-_@.]/gu; const invalidLatinChars = /[^\w\s\d\-_@.]/g; const trimRegex = /^\s+|\s+$/g; const collapseWhitespace = /\s+/g; @@ -28,13 +27,16 @@ if (isLatin.test(str)) { str = str.replace(invalidLatinChars, '-'); } else { - str = XRegExp.replace(str, invalidUnicodeChars, '-'); + str = str.replace(invalidUnicodeChars, '-'); } str = !preserveCase ? str.toLocaleLowerCase() : str; str = str.replace(collapseWhitespace, '-'); str = str.replace(collapseDash, '-'); str = str.replace(trimTrailingDash, ''); str = str.replace(trimLeadingDash, ''); + if (str === '.' || str === '..') { + return ''; + } return str; }; })); diff --git a/public/src/utils.common.js b/public/src/utils.common.js index 2035261755..bf9ebfcb76 100644 --- a/public/src/utils.common.js +++ b/public/src/utils.common.js @@ -337,6 +337,13 @@ const utils = { return (/^['" \-+.*[\]0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name)); }, + isSlugValid: function (slug) { + if (!slug || slug === '' || slug === '.' || slug === '..') return false; + if (slug.trim().length === 0) return false; + if (invisibleChars.test(slug)) return false; + return true; + }, + isPasswordValid: function (password) { return typeof password === 'string' && password.length; }, diff --git a/src/user/create.js b/src/user/create.js index c66de5c174..d0a6afb46d 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -156,7 +156,7 @@ module.exports = function (User) { throw new Error('[[error:invalid-email]]'); } - if (!utils.isUserNameValid(userData.username) || !userData.userslug) { + if (!utils.isUserNameValid(userData.username) || !utils.isSlugValid(userData.userslug)) { throw new Error(`[[error:invalid-username, ${userData.username}]]`); } diff --git a/src/user/profile.js b/src/user/profile.js index b5e878eafa..8602461629 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -173,7 +173,7 @@ module.exports = function (User) { User.checkUsernameLength(data.username); const userslug = slugify(data.username); - if (!utils.isUserNameValid(data.username) || !userslug) { + if (!utils.isUserNameValid(data.username) || !utils.isSlugValid(userslug)) { throw new Error('[[error:invalid-username]]'); } diff --git a/test/slugify.js b/test/slugify.js new file mode 100644 index 0000000000..772f708d45 --- /dev/null +++ b/test/slugify.js @@ -0,0 +1,164 @@ + +const assert = require('assert'); + +const utils = require('../src/utils'); +const slugify = require('../src/slugify'); + +describe('slugify', () => { + it('should replace spaces with dashes', () => { + assert.strictEqual(slugify('some username'), 'some-username'); + }); + + it('should collapse multiple spaces into one dash', () => { + assert.strictEqual(slugify('some username'), 'some-username'); + }); + + it('should trim leading and trailing whitespace', () => { + assert.strictEqual(slugify(' some username '), 'some-username'); + }); + + it('should lowercase by default', () => { + assert.strictEqual(slugify('Some Username'), 'some-username'); + }); + + it('should preserve case if requested', () => { + assert.strictEqual(slugify('UPPER CASE', true), 'UPPER-CASE'); + }); + + it('should work if a number is passed in', () => { + assert.strictEqual(slugify(12345), '12345'); + }); + + describe('dash normalization', () => { + it('should collapse multiple dashes', () => { + assert.strictEqual(slugify('foo---bar'), 'foo-bar'); + }); + + it('should trim leading dashes', () => { + assert.strictEqual(slugify('---foo'), 'foo'); + }); + + it('should trim trailing dashes', () => { + assert.strictEqual(slugify('foo---'), 'foo'); + }); + + it('should replace invalid characters with dashes', () => { + assert.strictEqual(slugify('foo!@#$bar'), 'foo-@-bar'); + }); + }); + + describe('unicode support', () => { + it('should preserve accented Latin characters', () => { + assert.strictEqual(slugify('Jöhn Döe'), 'jöhn-döe'); + }); + + it('should preserve Cyrillic characters', () => { + assert.strictEqual(slugify('Мария Иванова'), 'мария-иванова'); + }); + + it('should preserve CJK characters', () => { + assert.strictEqual(slugify('你好 世界'), '你好-世界'); + }); + + it('should preserve Arabic characters', () => { + assert.strictEqual(slugify('مرحبا بك'), 'مرحبا-بك'); + }); + + it('should replace invalid unicode symbols', () => { + assert.strictEqual(slugify('用户💩名'), '用户-名'); + }); + + }); + + describe('edge cases', () => { + it('should not return an empty string for punctuation-only input', () => { + assert.strictEqual(slugify('---'), ''); + }); + + it('should preserve dots inside slugs', () => { + assert.strictEqual(slugify('john.doe'), 'john.doe'); + }); + + it('should not return dot or dot-dot slugs', () => { + assert.strictEqual(slugify('-.-'), ''); + assert.strictEqual(slugify('.'), ''); + assert.strictEqual(slugify('..'), ''); + }); + + it('should handle dot-heavy usernames', () => { + assert.strictEqual(slugify('-.-.-'), '.-.'); + }); + + it('should return empty string for falsy input', () => { + assert.strictEqual(slugify(''), ''); + assert.strictEqual(slugify(null), ''); + assert.strictEqual(slugify(undefined), ''); + }); + }); +}); + +describe('isSlugValid', () => { + const { isSlugValid } = utils; + it('should reject empty or falsy values', () => { + assert.strictEqual(isSlugValid(''), false); + assert.strictEqual(isSlugValid(null), false); + assert.strictEqual(isSlugValid(undefined), false); + }); + + it('should reject dot and dot-dot', () => { + assert.strictEqual(isSlugValid('.'), false); + assert.strictEqual(isSlugValid('..'), false); + }); + + it('should reject whitespace-only slugs', () => { + assert.strictEqual(isSlugValid(' '), false); + assert.strictEqual(isSlugValid(' '), false); + }); + + it('should accept ASCII alphanumeric slugs', () => { + assert.strictEqual(isSlugValid('user123'), true); + assert.strictEqual(isSlugValid('john-doe'), true); + assert.strictEqual(isSlugValid('john.doe'), true); + }); + + it('should accept Unicode letter slugs', () => { + assert.strictEqual(isSlugValid('мария'), true); + assert.strictEqual(isSlugValid('ユーザー'), true); + assert.strictEqual(isSlugValid('你好'), true); + assert.strictEqual(isSlugValid('مرحبا'), true); + }); + + it('should accept mixed Unicode and punctuation slugs', () => { + assert.strictEqual(isSlugValid('用户-123'), true); + assert.strictEqual(isSlugValid('мария-иванова'), true); + assert.strictEqual(isSlugValid('ユーザー_01'), true); + }); + + it('should reject zero-width character slugs', () => { + assert.strictEqual(isSlugValid('\u200B'), false); // zero-width space + assert.strictEqual(isSlugValid('\u200D'), false); // zero-width joiner + }); + + it('slugify output should always produce a valid slug or empty string', () => { + const inputs = [ + 'some username', + '-.-', + '用户💩名', + '---', + 'Мария Иванова', + ' ', + '12345', + ]; + + for (const input of inputs) { + const slug = slugify(input); + if (slug !== '') { + assert.strictEqual( + isSlugValid(slug), + true, + `Expected valid slug from "${input}", got "${slug}"` + ); + } + } + }); +}); diff --git a/test/user.js b/test/user.js index af2b2f4e25..0fc1b4ef20 100644 --- a/test/user.js +++ b/test/user.js @@ -171,6 +171,23 @@ describe('User', () => { ]); assert.strictEqual(err.message, '[[error:email-taken]]'); }); + + it('should fail to create user with invalid slug', async () => { + await assert.rejects(User.create({ + username: '-.-', // slug becomes . + password: '123456', + }), { message: '[[error:invalid-username, -.-]]' }); + }); + + it('should create user with valid slug (-.-.- => .-.)', async () => { + const uid = await User.create({ + username: '-.-.-', // slug becomes .-. + password: '123456', + }); + const data = await User.getUserData(uid); + assert.strictEqual(data.username, '-.-.-'); + assert.strictEqual(data.userslug, '.-.'); + }); }); describe('.uniqueUsername()', () => { diff --git a/test/utils.js b/test/utils.js index 1d801b3338..64c7342ab0 100644 --- a/test/utils.js +++ b/test/utils.js @@ -4,7 +4,6 @@ const assert = require('assert'); const validator = require('validator'); const { JSDOM } = require('jsdom'); -const slugify = require('../src/slugify'); const db = require('./mocks/databasemock'); describe('Utility Methods', () => { @@ -70,16 +69,6 @@ describe('Utility Methods', () => { }); }); - it('should preserve case if requested', (done) => { - assert.strictEqual(slugify('UPPER CASE', true), 'UPPER-CASE'); - done(); - }); - - it('should work if a number is passed in', (done) => { - assert.strictEqual(slugify(12345), '12345'); - done(); - }); - describe('username validation', () => { it('accepts latin-1 characters', () => { const username = "John\"'-. Doeäâèéë1234";