Ghost/core/server/data/validation/index.js
Aileen Nowak c8cbbc4eb6 Improved password validation rules (#9171)
refs #9150 

- Moves the password length fn from `models/user` to `data/validation` where the other validator functions live.
- Added password validation rules. Password rules added:
   - Disallow obviously bad passwords: '1234567890', 'qwertyuiop', 'asdfghjkl;' and 'asdfghjklm' for example
   - Disallow passwords that contain the words 'password' or 'ghost'
   - Disallow passwords that match the user's email address
   - Disallow passwords that match the blog domain or blog title
   - Disallow passwords that include 50% or more of the same characters: 'aaaaaaaaaa', '1111111111' and 'ababababab' for example.
- Password validation returns an `Object` now, that includes an `isValid` and `message` property to differentiate between the two error messages (password too short or password insecure).
- Use a catch predicate in `api/authentication` on `passwordReset`, so the correct `ValidationError` will be thrown during the password reset flow rather then an `UnauthorizedError`.
- When in setup flow, the blog title is not available yet from `settingsCache`. We therefore supply it from the received form data in the user model `setup` method to have it accessible for the validation.
2017-10-26 11:01:24 +01:00

297 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

var schema = require('../schema').tables,
_ = require('lodash'),
validator = require('validator'),
moment = require('moment-timezone'),
assert = require('assert'),
Promise = require('bluebird'),
errors = require('../../errors'),
i18n = require('../../i18n'),
settingsCache = require('../../settings/cache'),
utils = require('../../utils/url'),
validatePassword,
validateSchema,
validateSettings,
validate;
function assertString(input) {
assert(typeof input === 'string', 'Validator js validates strings only');
}
/**
* Counts repeated characters in a string. When 50% or more characters are the same,
* we return false and therefore invalidate the string.
* @param {String} stringToTest The password string to check.
* @return {Boolean}
*/
function characterOccurance(stringToTest) {
var chars = {},
allowedOccurancy,
valid = true;
stringToTest = _.toString(stringToTest);
allowedOccurancy = stringToTest.length / 2;
// Loop through string and accumulate character counts
_.each(stringToTest, function (char) {
if (!chars[char]) {
chars[char] = 1;
} else {
chars[char] += 1;
}
});
// check if any of the accumulated chars exceed the allowed occurancy
// of 50% of the words' length.
_.forIn(chars, function (charCount) {
if (charCount >= allowedOccurancy) {
valid = false;
return;
}
});
return valid;
}
// extends has been removed in validator >= 5.0.0, need to monkey-patch it back in
validator.extend = function (name, fn) {
validator[name] = function () {
var args = Array.prototype.slice.call(arguments);
assertString(args[0]);
return fn.apply(validator, args);
};
};
// Provide a few custom validators
validator.extend('empty', function empty(str) {
return _.isEmpty(str);
});
validator.extend('notContains', function notContains(str, badString) {
return !_.includes(str, badString);
});
validator.extend('isTimezone', function isTimezone(str) {
return moment.tz.zone(str) ? true : false;
});
validator.extend('isEmptyOrURL', function isEmptyOrURL(str) {
return (_.isEmpty(str) || validator.isURL(str, {require_protocol: false}));
});
validator.extend('isSlug', function isSlug(str) {
return validator.matches(str, /^[a-z0-9\-_]+$/);
});
/**
* Validation against simple password rules
* Returns false when validation fails and true for a valid password
* @param {String} password The password string to check.
* @param {String} email The users email address to validate agains password.
* @param {String} blogTitle Optional blogTitle value, when blog title is not set yet, e. g. in setup process.
* @return {Object} example for returned validation Object:
* invalid password: `validationResult: {isValid: false, message: 'Sorry, you cannot use an insecure password.'}`
* valid password: `validationResult: {isValid: true}`
*/
validatePassword = function validatePassword(password, email, blogTitle) {
var validationResult = {isValid: true},
disallowedPasswords = ['password', 'ghost', 'passw0rd'],
blogUrl = utils.urlFor('home', true),
badPasswords = [
'1234567890',
'qwertyuiop',
'qwertzuiop',
'asdfghjkl;',
'abcdefghij',
'0987654321',
'1q2w3e4r5t',
'12345asdfg'
];
blogTitle = blogTitle ? blogTitle : settingsCache.get('title');
blogUrl = blogUrl.replace(/^http(s?):\/\//, '');
// password must be longer than 10 characters
if (!validator.isLength(password, 10)) {
validationResult.isValid = false;
validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplyLength', {minLength: 10});
return validationResult;
}
// dissallow password from badPasswords list (e. g. '1234567890')
_.each(badPasswords, function (badPassword) {
if (badPassword === password) {
validationResult.isValid = false;
}
});
// password must not match with users' email
if (email && email.toLowerCase() === password.toLowerCase()) {
validationResult.isValid = false;
}
// password must not contain the words 'ghost', 'password', or 'passw0rd'
_.each(disallowedPasswords, function (disallowedPassword) {
if (password.toLowerCase().indexOf(disallowedPassword) >= 0) {
validationResult.isValid = false;
}
});
// password must not match with blog title
if (blogTitle && blogTitle.toLowerCase() === password.toLowerCase()) {
validationResult.isValid = false;
}
// password must not match with blog URL (without protocol, with or without trailing slash)
if (blogUrl && (blogUrl.toLowerCase() === password.toLowerCase() || blogUrl.toLowerCase().replace(/\/$/, '') === password.toLowerCase())) {
validationResult.isValid = false;
}
// dissallow passwords where 50% or more of characters are the same
if (!characterOccurance(password)) {
validationResult.isValid = false;
}
// Generic error message for the rules where no dedicated error massage is set
if (!validationResult.isValid && !validationResult.message) {
validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplySecurity');
}
return validationResult;
};
// Validation against schema attributes
// values are checked against the validation objects from schema.js
validateSchema = function validateSchema(tableName, model) {
var columns = _.keys(schema[tableName]),
validationErrors = [];
_.each(columns, function each(columnKey) {
var message = '',
strVal = _.toString(model[columnKey]);
// check nullable
if (model.hasOwnProperty(columnKey) && schema[tableName][columnKey].hasOwnProperty('nullable')
&& schema[tableName][columnKey].nullable !== true) {
if (validator.empty(strVal)) {
message = i18n.t('notices.data.validation.index.valueCannotBeBlank', {tableName: tableName, columnKey: columnKey});
validationErrors.push(new errors.ValidationError({message: message, context: tableName + '.' + columnKey}));
}
}
// validate boolean columns
if (model.hasOwnProperty(columnKey) && schema[tableName][columnKey].hasOwnProperty('type')
&& schema[tableName][columnKey].type === 'bool') {
if (!(validator.isBoolean(strVal) || validator.empty(strVal))) {
message = i18n.t('notices.data.validation.index.valueMustBeBoolean', {tableName: tableName, columnKey: columnKey});
validationErrors.push(new errors.ValidationError({message: message, context: tableName + '.' + columnKey}));
}
}
// TODO: check if mandatory values should be enforced
if (model[columnKey] !== null && model[columnKey] !== undefined) {
// check length
if (schema[tableName][columnKey].hasOwnProperty('maxlength')) {
if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) {
message = i18n.t('notices.data.validation.index.valueExceedsMaxLength',
{tableName: tableName, columnKey: columnKey, maxlength: schema[tableName][columnKey].maxlength});
validationErrors.push(new errors.ValidationError({message: message, context: tableName + '.' + columnKey}));
}
}
// check validations objects
if (schema[tableName][columnKey].hasOwnProperty('validations')) {
validationErrors = validationErrors.concat(validate(strVal, columnKey, schema[tableName][columnKey].validations));
}
// check type
if (schema[tableName][columnKey].hasOwnProperty('type')) {
if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) {
message = i18n.t('notices.data.validation.index.valueIsNotInteger', {tableName: tableName, columnKey: columnKey});
validationErrors.push(new errors.ValidationError({message: message, context: tableName + '.' + columnKey}));
}
}
}
});
if (validationErrors.length !== 0) {
return Promise.reject(validationErrors);
}
return Promise.resolve();
};
// Validation for settings
// settings are checked against the validation objects
// form default-settings.json
validateSettings = function validateSettings(defaultSettings, model) {
var values = model.toJSON(),
validationErrors = [],
matchingDefault = defaultSettings[values.key];
if (matchingDefault && matchingDefault.validations) {
validationErrors = validationErrors.concat(validate(values.value, values.key, matchingDefault.validations));
}
if (validationErrors.length !== 0) {
return Promise.reject(validationErrors);
}
return Promise.resolve();
};
// Validate default settings using the validator module.
// Each validation's key is a method name and its value is an array of options
//
// eg:
// validations: { isURL: true, isLength: [20, 40] }
//
// will validate that a setting's length is a URL between 20 and 40 chars.
//
// If you pass a boolean as the value, it will specify the "good" result. By default
// the "good" result is assumed to be true.
//
// eg:
// validations: { isNull: false } // means the "good" result would
// // fail the `isNull` check, so
// // not null.
//
// available validators: https://github.com/chriso/validator.js#validators
validate = function validate(value, key, validations) {
var validationErrors = [];
value = _.toString(value);
_.each(validations, function each(validationOptions, validationName) {
var goodResult = true;
if (_.isBoolean(validationOptions)) {
goodResult = validationOptions;
validationOptions = [];
} else if (!_.isArray(validationOptions)) {
validationOptions = [validationOptions];
}
validationOptions.unshift(value);
// equivalent of validator.isSomething(option1, option2)
if (validator[validationName].apply(validator, validationOptions) !== goodResult) {
validationErrors.push(new errors.ValidationError({
message: i18n.t('notices.data.validation.index.validationFailed', {validationName: validationName, key: key})
}));
}
validationOptions.shift();
}, this);
return validationErrors;
};
module.exports = {
validate: validate,
validator: validator,
validatePassword: validatePassword,
validateSchema: validateSchema,
validateSettings: validateSettings
};