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, };