diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index 9025afb326..b2d6121741 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -91,7 +91,15 @@ describe('PUT /user', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('displaynameIssueSlur'), + message: t('bannedSlurUsedInProfile'), + }); + + await expect(user.put('/user', { + 'profile.name': 'TESTPLACEHOLDERSWEARWORDHERE', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('bannedWordUsedInProfile'), }); await expect(user.put('/user', { diff --git a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js index 7e2da12bc0..bb8f0827c8 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js @@ -139,7 +139,7 @@ describe('PUT /user/auth/update-username', async () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), + message: [t('usernameIssueLength'), t('bannedSlurUsedInProfile')].join(' '), }); }); @@ -149,21 +149,14 @@ describe('PUT /user/auth/update-username', async () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), + message: [t('usernameIssueLength'), t('bannedSlurUsedInProfile')].join(' '), }); await expect(user.put(ENDPOINT, { username: 'something_TESTPLACEHOLDERSLURWORDHERE', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), - }); - await expect(user.put(ENDPOINT, { - username: 'somethingTESTPLACEHOLDERSLURWORDHEREotherword', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), + message: [t('usernameIssueLength'), t('bannedSlurUsedInProfile')].join(' '), }); }); diff --git a/test/api/v4/user/auth/POST-user_verify_display_name.test.js b/test/api/v4/user/auth/POST-user_verify_display_name.test.js index 2b56f137db..a55cc029f3 100644 --- a/test/api/v4/user/auth/POST-user_verify_display_name.test.js +++ b/test/api/v4/user/auth/POST-user_verify_display_name.test.js @@ -33,19 +33,22 @@ describe('POST /user/auth/verify-display-name', async () => { it('errors if display name is a slur', async () => { await expect(user.post(ENDPOINT, { displayName: 'TESTPLACEHOLDERSLURWORDHERE', - })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueSlur')] }); + })).to.eventually.eql({ isUsable: false, issues: [t('bannedSlurUsedInProfile')] }); }); it('errors if display name contains a slur', async () => { await expect(user.post(ENDPOINT, { displayName: 'TESTPLACEHOLDERSLURWORDHERE_otherword', - })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] }); + })).to.eventually.eql({ + isUsable: false, + issues: [t('displaynameIssueLength'), t('bannedSlurUsedInProfile')], + }); await expect(user.post(ENDPOINT, { displayName: 'something_TESTPLACEHOLDERSLURWORDHERE', - })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] }); - await expect(user.post(ENDPOINT, { - displayName: 'somethingTESTPLACEHOLDERSLURWORDHEREotherword', - })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] }); + })).to.eventually.eql({ + isUsable: false, + issues: [t('displaynameIssueLength'), t('bannedSlurUsedInProfile')], + }); }); it('errors if display name has incorrect length', async () => { diff --git a/test/api/v4/user/auth/POST-user_verify_username.test.js b/test/api/v4/user/auth/POST-user_verify_username.test.js index 2ae95b1c52..e7aa019e3e 100644 --- a/test/api/v4/user/auth/POST-user_verify_username.test.js +++ b/test/api/v4/user/auth/POST-user_verify_username.test.js @@ -51,9 +51,6 @@ describe('POST /user/auth/verify-username', async () => { await expect(user.post(ENDPOINT, { username: 'something_TESTPLACEHOLDERSLURWORDHERE', })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueLength'), t('usernameIssueSlur')] }); - await expect(user.post(ENDPOINT, { - username: 'somethingTESTPLACEHOLDERSLURWORDHEREotherword', - })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueLength'), t('usernameIssueSlur')] }); }); it('errors if username is not allowed', async () => { diff --git a/website/common/locales/en/settings.json b/website/common/locales/en/settings.json index 601f48660a..69f0920521 100644 --- a/website/common/locales/en/settings.json +++ b/website/common/locales/en/settings.json @@ -173,11 +173,16 @@ "usernameIssueInvalidCharacters": "Usernames can only contain letters a to z, numbers 0 to 9, hyphens, or underscores.", "currentUsername": "Current username:", "displaynameIssueLength": "Display Names must be between 1 and 30 characters.", - "displaynameIssueSlur": "Display Names may not contain inappropriate language.", + "bannedWordUsedInProfile": "Your Display Name or About text contained inappropriate language.", "displaynameIssueNewline": "Display Names may not contain backslashes followed by the letter N.", "goToSettings": "Go to Settings", "usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!", "usernameNotVerified": "Please confirm your username.", "changeUsernameDisclaimer": "Your username is used for invitations, @mentions in chat, and messaging. It must be 1 to 20 characters, containing only letters a to z, numbers 0 to 9, hyphens, or underscores, and cannot include any inappropriate terms.", - "verifyUsernameVeteranPet": "One of these Veteran Pets will be waiting for you after you've finished confirming!" + "verifyUsernameVeteranPet": "One of these Veteran Pets will be waiting for you after you've finished confirming!", + "mentioning": "Mentioning", + "suggestMyUsername": "Suggest my username", + "everywhere": "Everywhere", + "onlyPrivateSpaces": "Only in private spaces", + "bannedSlurUsedInProfile": "Your Display Name or About text contained a slur, and your chat privileges have been revoked." } diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index c73384010f..7860a3db61 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -213,7 +213,7 @@ api.updateUsername = { const newUsername = req.body.username; - const issues = verifyUsername(newUsername, res); + const issues = verifyUsername(newUsername, res, false); if (issues.length > 0) throw new BadRequest(issues.join(' ')); const { password } = req.body; diff --git a/website/server/libs/stringUtils.js b/website/server/libs/stringUtils.js index 39f75356c5..d675e43c69 100644 --- a/website/server/libs/stringUtils.js +++ b/website/server/libs/stringUtils.js @@ -1,3 +1,7 @@ +export function normalizeUnicodeString (str) { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + export function removePunctuationFromString (str) { return str.replace(/[.,/#!@$%^&;:{}=\-_`~()]/g, ' '); } @@ -12,15 +16,16 @@ export function getMatchesByWordArray (str, wordsToMatch) { // https://www.unicode.org/reports/tr15/#Canon_Compat_Equivalence // https://unicode-table.com/en/#combining-diacritical-marks - const normalizedStr = str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); - const matchedWords = []; - const wordRegexs = wordsToMatch.map(word => new RegExp(`\\b([^a-z]+)?${word}([^a-z]+)?\\b`, 'i')); + const wordRegexs = wordsToMatch.map(word => { + const normalizedWord = removePunctuationFromString(normalizeUnicodeString(word)); + return new RegExp(`\\b([^a-z]+)?${normalizedWord}([^a-z]+)?\\b`, 'i'); + }); for (let i = 0; i < wordRegexs.length; i += 1) { const regEx = wordRegexs[i]; - const match = normalizedStr.match(regEx); + const match = removePunctuationFromString(normalizeUnicodeString(str)).match(regEx); if (match !== null && match[0] !== null) { - const trimmedMatch = removePunctuationFromString(match[0]).trim(); + const trimmedMatch = match[0].trim(); matchedWords.push(trimmedMatch); } } diff --git a/website/server/libs/user/index.js b/website/server/libs/user/index.js index 9ff3ef81f4..f21573b345 100644 --- a/website/server/libs/user/index.js +++ b/website/server/libs/user/index.js @@ -7,7 +7,7 @@ import { } from '../errors'; import { model as User, schema as UserSchema } from '../../models/user'; import { model as NewsPost } from '../../models/newsPost'; -import { nameContainsSlur, nameContainsNewline } from './validation'; +import { stringContainsProfanity, nameContainsNewline } from './validation'; export async function get (req, res, { isV3 = false }) { const { user } = res.locals; @@ -101,6 +101,19 @@ function checkPreferencePurchase (user, path, item) { return _.get(user.purchased, itemPath); } +async function checkNewInputForProfanity (user, res, newValue) { + const containsSlur = stringContainsProfanity(newValue, 'slur'); + const containsBannedWord = stringContainsProfanity(newValue); + if (containsSlur || containsBannedWord) { + if (containsSlur) { + user.flags.chatRevoked = true; + await user.save(); + throw new BadRequest(res.t('bannedSlurUsedInProfile')); + } + throw new BadRequest(res.t('bannedWordUsedInProfile')); + } +} + export async function update (req, res, { isV3 = false }) { const { user } = res.locals; @@ -110,8 +123,13 @@ export async function update (req, res, { isV3 = false }) { const newName = req.body['profile.name']; if (newName === null) throw new BadRequest(res.t('invalidReqParams')); if (newName.length > 30) throw new BadRequest(res.t('displaynameIssueLength')); - if (nameContainsSlur(newName)) throw new BadRequest(res.t('displaynameIssueSlur')); if (nameContainsNewline(newName)) throw new BadRequest(res.t('displaynameIssueNewline')); + await checkNewInputForProfanity(user, res, newName); + } + + if (req.body['profile.blurb'] !== undefined) { + const newBlurb = req.body['profile.blurb']; + await checkNewInputForProfanity(user, res, newBlurb); } _.each(req.body, (val, key) => { diff --git a/website/server/libs/user/validation.js b/website/server/libs/user/validation.js index 48fb9eca73..3c3defb088 100644 --- a/website/server/libs/user/validation.js +++ b/website/server/libs/user/validation.js @@ -1,13 +1,23 @@ import bannedSlurs from '../bannedSlurs'; -import { getMatchesByWordArray } from '../stringUtils'; +import bannedWords from '../bannedWords'; +import { + getMatchesByWordArray, + normalizeUnicodeString, + removePunctuationFromString, +} from '../stringUtils'; import forbiddenUsernames from '../forbiddenUsernames'; -const bannedSlurRegexs = bannedSlurs.map(word => new RegExp(`.*${word}.*`, 'i')); +const bannedSlurRegexes = bannedSlurs.map(word => new RegExp(`\\b([^a-z]+)?${word}([^a-z]+)?\\b`, 'i')); +const bannedWordRegexes = bannedWords.map(word => new RegExp(`\\b([^a-z]+)?${word}([^a-z]+)?\\b`, 'i')); -export function nameContainsSlur (username) { - for (let i = 0; i < bannedSlurRegexs.length; i += 1) { - const regEx = bannedSlurRegexs[i]; - const match = username.match(regEx); +export function stringContainsProfanity (str, profanityType = 'bannedWord') { + const bannedRegexes = profanityType === 'slur' + ? bannedSlurRegexes + : bannedWordRegexes; + + for (let i = 0; i < bannedRegexes.length; i += 1) { + const regEx = bannedRegexes[i]; + const match = removePunctuationFromString(normalizeUnicodeString(str)).match(regEx); if (match !== null && match[0] !== null) { return true; } @@ -33,17 +43,21 @@ function usernameContainsInvalidCharacters (username) { export function verifyDisplayName (displayName, res) { const issues = []; if (displayName.length < 1 || displayName.length > 30) issues.push(res.t('displaynameIssueLength')); - if (nameContainsSlur(displayName)) issues.push(res.t('displaynameIssueSlur')); + if (stringContainsProfanity(displayName)) issues.push(res.t('bannedWordUsedInProfile')); + if (stringContainsProfanity(displayName, 'slur')) issues.push(res.t('bannedSlurUsedInProfile')); if (nameContainsNewline(displayName)) issues.push(res.t('displaynameIssueNewline')); return issues; } -export function verifyUsername (username, res) { +export function verifyUsername (username, res, newUser = true) { + const slurMessage = newUser + ? res.t('usernameIssueSlur') + : res.t('bannedSlurUsedInProfile'); const issues = []; if (username.length < 1 || username.length > 20) issues.push(res.t('usernameIssueLength')); if (usernameContainsInvalidCharacters(username)) issues.push(res.t('usernameIssueInvalidCharacters')); - if (nameContainsSlur(username)) issues.push(res.t('usernameIssueSlur')); + if (stringContainsProfanity(username, 'slur') || stringContainsProfanity(username)) issues.push(slurMessage); if (usernameIsForbidden(username)) issues.push(res.t('usernameIssueForbidden')); return issues;