mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-02-14 02:27:49 +01:00
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
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}`, {}),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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}]]`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]]');
|
||||
}
|
||||
|
||||
|
||||
164
test/slugify.js
Normal file
164
test/slugify.js
Normal file
@@ -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}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
17
test/user.js
17
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()', () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user