234 lines
7.2 KiB
JavaScript
234 lines
7.2 KiB
JavaScript
const { isDisposableEmail } = require('fakeout');
|
|
|
|
const LIMITS = {
|
|
name: 100,
|
|
username: 50,
|
|
email: 254,
|
|
password: 128,
|
|
};
|
|
|
|
const MIN_PASSWORD_LENGTH = 12;
|
|
const COMMON_PASSWORDS = new Set([
|
|
'123456789', 'password', 'password123', 'qwerty',
|
|
'qwertyuiop', 'letmein', 'welcome', 'admin',
|
|
'iloveyou', 'monkey', 'dragon', 'abc123',
|
|
]);
|
|
|
|
function cleanText(value, { trim = true } = {}) {
|
|
if (typeof value !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
const cleaned = value.replace(/[\u0000-\u001F\u007F]/g, '');
|
|
return trim ? cleaned.trim() : cleaned;
|
|
}
|
|
|
|
function cleanPersonInput(input) {
|
|
return {
|
|
name: cleanText(input.name),
|
|
username: cleanText(input.username),
|
|
email: cleanText(input.email).toLowerCase(),
|
|
// Password whitespace is preserved, but control characters are removed.
|
|
password: cleanText(input.password, { trim: false }),
|
|
};
|
|
}
|
|
|
|
function validateCommonFields(values, { requireName = true } = {}) {
|
|
const fields = requireName
|
|
? ['name', 'username', 'email', 'password']
|
|
: ['email', 'password'];
|
|
|
|
for (const field of fields) {
|
|
if (!values[field] || values[field].length > LIMITS[field]) {
|
|
throw new Error(`${field} must be between 1 and ${LIMITS[field]} characters`);
|
|
}
|
|
}
|
|
|
|
if (/\s/.test(values.username)) {
|
|
throw new Error('username must not contain spaces');
|
|
}
|
|
|
|
if (/\s/.test(values.email)) {
|
|
throw new Error('email must not contain spaces');
|
|
}
|
|
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
|
|
throw new Error('email must be a valid email address');
|
|
}
|
|
}
|
|
|
|
function validatePasswordRules(password, { username, email }) {
|
|
if (!password || password.length > LIMITS.password) {
|
|
throw new Error(`password must be between 1 and ${LIMITS.password} characters`);
|
|
}
|
|
|
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
throw new Error(`password must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
}
|
|
|
|
const passwordLower = password.toLowerCase();
|
|
const normalizedPassword = passwordLower.replace(/[^a-z0-9]/g, '');
|
|
const emailLocalPart = email.split('@')[0];
|
|
if (
|
|
COMMON_PASSWORDS.has(normalizedPassword) ||
|
|
passwordLower.includes(username.toLowerCase()) ||
|
|
passwordLower.includes(emailLocalPart)
|
|
) {
|
|
throw new Error('password is too common or contains account details');
|
|
}
|
|
|
|
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password)) {
|
|
throw new Error('password must contain lowercase and uppercase letters');
|
|
}
|
|
|
|
if (!/[0-9]/.test(password)) {
|
|
throw new Error('password must contain a number');
|
|
}
|
|
|
|
if (!/[^a-zA-Z0-9\s]/.test(password)) {
|
|
throw new Error('password must contain a special character');
|
|
}
|
|
|
|
if (/(.)\1\1/.test(password) || /(0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef)/i.test(password)) {
|
|
throw new Error('password must not contain obvious repeated or sequential characters');
|
|
}
|
|
}
|
|
|
|
function validateRegistrationInput(values) {
|
|
validateCommonFields(values);
|
|
validatePasswordRules(values.password, {
|
|
username: values.username,
|
|
email: values.email,
|
|
});
|
|
|
|
if (isDisposableEmail(values.email)) {
|
|
throw new Error('temporary email addresses are not allowed');
|
|
}
|
|
}
|
|
|
|
function cleanProfileUpdateInput(input) {
|
|
const body = input || {};
|
|
const values = {};
|
|
|
|
for (const field of ['name', 'username', 'email', 'password', 'current_password']) {
|
|
if (body[field] !== undefined) {
|
|
const preserveWhitespace = field === 'password' || field === 'current_password';
|
|
let value = cleanText(body[field], { trim: !preserveWhitespace });
|
|
if (field === 'email') {
|
|
value = value.toLowerCase();
|
|
}
|
|
values[field] = value;
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
function validateOptionalPersonField(field, value) {
|
|
if (!value || value.length > LIMITS[field]) {
|
|
throw new Error(`${field} must be between 1 and ${LIMITS[field]} characters`);
|
|
}
|
|
|
|
if (field === 'username' && /\s/.test(value)) {
|
|
throw new Error('username must not contain spaces');
|
|
}
|
|
|
|
if (field === 'email') {
|
|
if (/\s/.test(value)) {
|
|
throw new Error('email must not contain spaces');
|
|
}
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
throw new Error('email must be a valid email address');
|
|
}
|
|
if (isDisposableEmail(value)) {
|
|
throw new Error('temporary email addresses are not allowed');
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateProfileUpdateInput(values, { hasImageChange = false } = {}) {
|
|
const changeFields = ['name', 'username', 'email', 'password'].filter((field) => values[field] !== undefined);
|
|
if (changeFields.length === 0 && !hasImageChange) {
|
|
throw new Error('At least one profile field must be changed');
|
|
}
|
|
|
|
if (values.password !== undefined && values.current_password === undefined) {
|
|
throw new Error('current_password is required when changing password');
|
|
}
|
|
if (values.current_password !== undefined && values.password === undefined) {
|
|
throw new Error('password is required when submitting current_password');
|
|
}
|
|
|
|
for (const field of ['name', 'username', 'email']) {
|
|
if (values[field] !== undefined) {
|
|
validateOptionalPersonField(field, values[field]);
|
|
}
|
|
}
|
|
|
|
if (values.current_password !== undefined && (
|
|
!values.current_password || values.current_password.length > LIMITS.password
|
|
)) {
|
|
throw new Error(`current_password must be between 1 and ${LIMITS.password} characters`);
|
|
}
|
|
}
|
|
|
|
function sanitizeProfileUpdate(req, res, next) {
|
|
try {
|
|
const values = cleanProfileUpdateInput(req.body);
|
|
const removeImageValue = req.body?.remove_image;
|
|
const hasImageChange = Boolean(req.file) || removeImageValue === 'true';
|
|
validateProfileUpdateInput(values, { hasImageChange });
|
|
req.profileUpdate = values;
|
|
next();
|
|
}
|
|
catch (err) {
|
|
res.status(400).send(err.message);
|
|
}
|
|
}
|
|
|
|
function sanitizeAccountDelete(req, res, next) {
|
|
try {
|
|
const password = cleanText((req.body || {}).password, { trim: false });
|
|
if (!password || password.length > LIMITS.password) {
|
|
throw new Error(`password must be between 1 and ${LIMITS.password} characters`);
|
|
}
|
|
req.accountDelete = { password };
|
|
next();
|
|
}
|
|
catch (err) {
|
|
res.status(400).send(err.message);
|
|
}
|
|
}
|
|
|
|
function sanitizePersonInput(req, res, next) {
|
|
try {
|
|
const values = cleanPersonInput(req.body || {});
|
|
validateRegistrationInput(values);
|
|
req.person = values;
|
|
next();
|
|
}
|
|
catch (err) {
|
|
res.status(400).send(err.message);
|
|
}
|
|
}
|
|
|
|
function sanitizeLoginInput(req, res, next) {
|
|
try {
|
|
const values = cleanPersonInput(req.body || {});
|
|
validateCommonFields(values, { requireName: false });
|
|
req.loginInput = { email: values.email, password: values.password };
|
|
next();
|
|
}
|
|
catch (err) {
|
|
res.status(400).send(err.message);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
sanitizePersonInput,
|
|
sanitizeLoginInput,
|
|
sanitizeProfileUpdate,
|
|
sanitizeAccountDelete,
|
|
validatePasswordRules,
|
|
};
|