mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-01 05:50:35 +03:00
Split validation index into separate files
- The data/validation module is made up of several loosely related things with lots of dependencies - Separating out the various components makes it possible to see what's what, and importantly what has complex dependencies - validator + validate probably go togetheri in an external module, the other two files should probably have their own homes in related areas of ghost e.g. schema -> data/schema/validate.js
This commit is contained in:
parent
846e9f1bbc
commit
c37de311ea
@ -1,343 +1,8 @@
|
||||
const schema = require('../schema').tables;
|
||||
const _ = require('lodash');
|
||||
const validator = require('validator');
|
||||
const moment = require('moment-timezone');
|
||||
const assert = require('assert');
|
||||
const Promise = require('bluebird');
|
||||
const i18n = require('../../../shared/i18n');
|
||||
const errors = require('@tryghost/errors');
|
||||
const settingsCache = require('../../services/settings/cache');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
|
||||
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) {
|
||||
const chars = {};
|
||||
let allowedOccurancy;
|
||||
let 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 valid;
|
||||
}
|
||||
|
||||
// extends has been removed in validator >= 5.0.0, need to monkey-patch it back in
|
||||
// @TODO: We modify the global validator dependency here! https://github.com/chriso/validator.js/issues/525#issuecomment-213149570
|
||||
validator.extend = function (name, fn) {
|
||||
validator[name] = function () {
|
||||
const 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}`
|
||||
*/
|
||||
function validatePassword(password, email, blogTitle) {
|
||||
const validationResult = {isValid: true};
|
||||
const disallowedPasswords = ['password', 'ghost', 'passw0rd'];
|
||||
let blogUrl = urlUtils.urlFor('home', true);
|
||||
|
||||
const 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model against schema.
|
||||
*
|
||||
* ## on model update
|
||||
* - only validate changed fields
|
||||
* - otherwise we could throw errors which the user is out of control
|
||||
* - e.g.
|
||||
* - we add a new field without proper validation, release goes out
|
||||
* - we add proper validation for a single field
|
||||
* - if you call `user.save()` the default fallback in bookshelf is `options.method=update`.
|
||||
* - we set `options.method` explicit for adding resources (because otherwise bookshelf uses `update`)
|
||||
*
|
||||
* ## on model add
|
||||
* - validate everything to catch required fields
|
||||
*/
|
||||
function validateSchema(tableName, model, options) {
|
||||
options = options || {};
|
||||
|
||||
const columns = _.keys(schema[tableName]);
|
||||
let validationErrors = [];
|
||||
|
||||
_.each(columns, function each(columnKey) {
|
||||
let message = ''; // KEEP: Validator.js only validates strings.
|
||||
const strVal = _.toString(model.get(columnKey));
|
||||
|
||||
if (options.method !== 'insert' && !_.has(model.changed, columnKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check nullable
|
||||
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') &&
|
||||
schema[tableName][columnKey].nullable !== true &&
|
||||
!Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo')
|
||||
) {
|
||||
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 (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], '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
|
||||
}));
|
||||
}
|
||||
|
||||
// CASE: ensure we transform 0|1 to false|true
|
||||
if (!validator.empty(strVal)) {
|
||||
model.set(columnKey, !!model.get(columnKey));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: check if mandatory values should be enforced
|
||||
if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) {
|
||||
// check length
|
||||
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], '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 (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) {
|
||||
validationErrors = validationErrors.concat(validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName));
|
||||
}
|
||||
|
||||
// check type
|
||||
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], '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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate keys 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 values'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
|
||||
* @param {String} value the value to validate.
|
||||
* @param {String} key the db column key of the value to validate.
|
||||
* @param {Object} validations the validations object as described above.
|
||||
* @param {String} tableName (optional) the db table of the value to validate, used for error message.
|
||||
* @return {Array} returns an Array including the found validation errors (empty if none found);
|
||||
*/
|
||||
function validate(value, key, validations, tableName) {
|
||||
const validationErrors = [];
|
||||
let translation;
|
||||
value = _.toString(value);
|
||||
|
||||
_.each(validations, function each(validationOptions, validationName) {
|
||||
let 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) {
|
||||
// CASE: You can define specific translations for validators e.g. isLength
|
||||
if (i18n.doesTranslationKeyExist('notices.data.validation.index.validationFailedTypes.' + validationName)) {
|
||||
translation = i18n.t('notices.data.validation.index.validationFailedTypes.' + validationName, _.merge({
|
||||
validationName: validationName,
|
||||
key: key,
|
||||
tableName: tableName
|
||||
}, validationOptions[1]));
|
||||
} else {
|
||||
translation = i18n.t('notices.data.validation.index.validationFailed', {
|
||||
validationName: validationName,
|
||||
key: key
|
||||
});
|
||||
}
|
||||
|
||||
validationErrors.push(new errors.ValidationError({
|
||||
message: translation,
|
||||
context: `${tableName}.${key}`
|
||||
}));
|
||||
}
|
||||
|
||||
validationOptions.shift();
|
||||
}, this);
|
||||
|
||||
return validationErrors;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
validator,
|
||||
validatePassword,
|
||||
validateSchema
|
||||
validate: require('./validate'),
|
||||
validator: require('./validator'),
|
||||
|
||||
// These two things are dependent on validator, not related
|
||||
validatePassword: require('./password'),
|
||||
validateSchema: require('./schema')
|
||||
};
|
||||
|
122
core/server/data/validation/password.js
Normal file
122
core/server/data/validation/password.js
Normal file
@ -0,0 +1,122 @@
|
||||
const _ = require('lodash');
|
||||
|
||||
const validator = require('./validator');
|
||||
|
||||
const i18n = require('../../../shared/i18n');
|
||||
const settingsCache = require('../../services/settings/cache');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const chars = {};
|
||||
let allowedOccurancy;
|
||||
let 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 valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
*/
|
||||
function validatePassword(password, email, blogTitle) {
|
||||
const validationResult = {isValid: true};
|
||||
const disallowedPasswords = ['password', 'ghost', 'passw0rd'];
|
||||
let blogUrl = urlUtils.urlFor('home', true);
|
||||
|
||||
const 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;
|
||||
}
|
||||
|
||||
module.exports = validatePassword;
|
122
core/server/data/validation/schema.js
Normal file
122
core/server/data/validation/schema.js
Normal file
@ -0,0 +1,122 @@
|
||||
const _ = require('lodash');
|
||||
const Promise = require('bluebird');
|
||||
|
||||
const i18n = require('../../../shared/i18n');
|
||||
const errors = require('@tryghost/errors');
|
||||
|
||||
const schema = require('../schema').tables;
|
||||
const validator = require('./validator');
|
||||
const validate = require('./validate');
|
||||
|
||||
/**
|
||||
* Validate model against schema.
|
||||
*
|
||||
* ## on model update
|
||||
* - only validate changed fields
|
||||
* - otherwise we could throw errors which the user is out of control
|
||||
* - e.g.
|
||||
* - we add a new field without proper validation, release goes out
|
||||
* - we add proper validation for a single field
|
||||
* - if you call `user.save()` the default fallback in bookshelf is `options.method=update`.
|
||||
* - we set `options.method` explicit for adding resources (because otherwise bookshelf uses `update`)
|
||||
*
|
||||
* ## on model add
|
||||
* - validate everything to catch required fields
|
||||
*/
|
||||
function validateSchema(tableName, model, options) {
|
||||
options = options || {};
|
||||
|
||||
const columns = _.keys(schema[tableName]);
|
||||
let validationErrors = [];
|
||||
|
||||
_.each(columns, function each(columnKey) {
|
||||
let message = ''; // KEEP: Validator.js only validates strings.
|
||||
const strVal = _.toString(model.get(columnKey));
|
||||
|
||||
if (options.method !== 'insert' && !_.has(model.changed, columnKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check nullable
|
||||
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') &&
|
||||
schema[tableName][columnKey].nullable !== true &&
|
||||
!Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo')
|
||||
) {
|
||||
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 (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], '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
|
||||
}));
|
||||
}
|
||||
|
||||
// CASE: ensure we transform 0|1 to false|true
|
||||
if (!validator.empty(strVal)) {
|
||||
model.set(columnKey, !!model.get(columnKey));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: check if mandatory values should be enforced
|
||||
if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) {
|
||||
// check length
|
||||
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], '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 (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) {
|
||||
validationErrors = validationErrors.concat(validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName));
|
||||
}
|
||||
|
||||
// check type
|
||||
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], '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();
|
||||
}
|
||||
module.exports = validateSchema;
|
72
core/server/data/validation/validate.js
Normal file
72
core/server/data/validation/validate.js
Normal file
@ -0,0 +1,72 @@
|
||||
const _ = require('lodash');
|
||||
const validator = require('./validator');
|
||||
const i18n = require('../../../shared/i18n');
|
||||
const errors = require('@tryghost/errors');
|
||||
|
||||
/**
|
||||
* Validate keys 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 values'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
|
||||
* @param {String} value the value to validate.
|
||||
* @param {String} key the db column key of the value to validate.
|
||||
* @param {Object} validations the validations object as described above.
|
||||
* @param {String} tableName (optional) the db table of the value to validate, used for error message.
|
||||
* @return {Array} returns an Array including the found validation errors (empty if none found);
|
||||
*/
|
||||
function validate(value, key, validations, tableName) {
|
||||
const validationErrors = [];
|
||||
let translation;
|
||||
value = _.toString(value);
|
||||
|
||||
_.each(validations, function each(validationOptions, validationName) {
|
||||
let 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) {
|
||||
// CASE: You can define specific translations for validators e.g. isLength
|
||||
if (i18n.doesTranslationKeyExist('notices.data.validation.index.validationFailedTypes.' + validationName)) {
|
||||
translation = i18n.t('notices.data.validation.index.validationFailedTypes.' + validationName, _.merge({
|
||||
validationName: validationName,
|
||||
key: key,
|
||||
tableName: tableName
|
||||
}, validationOptions[1]));
|
||||
} else {
|
||||
translation = i18n.t('notices.data.validation.index.validationFailed', {
|
||||
validationName: validationName,
|
||||
key: key
|
||||
});
|
||||
}
|
||||
|
||||
validationErrors.push(new errors.ValidationError({
|
||||
message: translation,
|
||||
context: `${tableName}.${key}`
|
||||
}));
|
||||
}
|
||||
|
||||
validationOptions.shift();
|
||||
}, this);
|
||||
|
||||
return validationErrors;
|
||||
}
|
||||
|
||||
module.exports = validate;
|
42
core/server/data/validation/validator.js
Normal file
42
core/server/data/validation/validator.js
Normal file
@ -0,0 +1,42 @@
|
||||
const _ = require('lodash');
|
||||
|
||||
const validator = require('validator');
|
||||
const moment = require('moment-timezone');
|
||||
const assert = require('assert');
|
||||
|
||||
function assertString(input) {
|
||||
assert(typeof input === 'string', 'Validator js validates strings only');
|
||||
}
|
||||
|
||||
// extends has been removed in validator >= 5.0.0, need to monkey-patch it back in
|
||||
// @TODO: We modify the global validator dependency here! https://github.com/chriso/validator.js/issues/525#issuecomment-213149570
|
||||
validator.extend = function (name, fn) {
|
||||
validator[name] = function () {
|
||||
const 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\-_]+$/);
|
||||
});
|
||||
|
||||
module.exports = validator;
|
@ -1,21 +1,14 @@
|
||||
const should = require('should');
|
||||
const _ = require('lodash');
|
||||
const ObjectId = require('bson-objectid');
|
||||
const testUtils = require('../../../utils');
|
||||
const models = require('../../../../core/server/models');
|
||||
|
||||
const validation = require('../../../../core/server/data/validation');
|
||||
|
||||
// Validate our customizations
|
||||
describe('Validation', function () {
|
||||
before(function () {
|
||||
models.init();
|
||||
});
|
||||
|
||||
it('should export our required functions', function () {
|
||||
should.exist(validation);
|
||||
|
||||
validation.should.have.properties(
|
||||
['validate', 'validator', 'validateSchema']
|
||||
['validate', 'validator', 'validateSchema', 'validatePassword']
|
||||
);
|
||||
|
||||
validation.validate.should.be.a.Function();
|
||||
@ -24,160 +17,4 @@ describe('Validation', function () {
|
||||
|
||||
validation.validator.should.have.properties(['empty', 'notContains', 'isTimezone', 'isEmptyOrURL', 'isSlug']);
|
||||
});
|
||||
|
||||
describe('Validate Schema', function () {
|
||||
describe('models.add', function () {
|
||||
it('blank model', function () {
|
||||
// NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level.
|
||||
return validation.validateSchema('posts', models.Post.forge(), {method: 'insert'})
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(7);
|
||||
|
||||
const errorMessages = _.map(err, function (object) {
|
||||
return object.message;
|
||||
}).join(',');
|
||||
|
||||
// NOTE: Some of these fields are auto-filled in the model layer (e.g. author_id, created_at etc.)
|
||||
['id', 'uuid', 'slug', 'title', 'author_id', 'created_at', 'created_by'].forEach(function (attr) {
|
||||
errorMessages.should.match(new RegExp('posts.' + attr));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('blank id', function () {
|
||||
const postModel = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({
|
||||
id: null,
|
||||
slug: 'test'
|
||||
}));
|
||||
|
||||
return validation.validateSchema('posts', postModel, {method: 'insert'})
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].message.should.match(/posts\.id/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass', function () {
|
||||
return validation.validateSchema(
|
||||
'posts',
|
||||
models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'title'})),
|
||||
{method: 'insert'}
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms 0 and 1', function () {
|
||||
const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: 0}));
|
||||
post.get('featured').should.eql(0);
|
||||
|
||||
return validation.validateSchema('posts', post, {method: 'insert'})
|
||||
.then(function () {
|
||||
post.get('featured').should.eql(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps true or false', function () {
|
||||
const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: true}));
|
||||
post.get('featured').should.eql(true);
|
||||
|
||||
return validation.validateSchema('posts', post, {method: 'insert'})
|
||||
.then(function () {
|
||||
post.get('featured').should.eql(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhooks.add', function () {
|
||||
it('event name is not lowercase', function () {
|
||||
const webhook = models.Webhook.forge(testUtils.DataGenerator.forKnex.createWebhook({
|
||||
event: 'Test',
|
||||
integration_id: testUtils.DataGenerator.Content.integrations[0].id
|
||||
}));
|
||||
|
||||
// NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level.
|
||||
return validation.validateSchema('webhooks', webhook, {method: 'insert'})
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].errorType.should.eql('ValidationError');
|
||||
err[0].message.should.match(/isLowercase/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('models.edit', function () {
|
||||
it('uuid is invalid', function () {
|
||||
const postModel = models.Post.forge({id: ObjectId().toHexString(), uuid: '1234'});
|
||||
|
||||
postModel.changed = {uuid: postModel.get('uuid')};
|
||||
|
||||
return validation.validateSchema('posts', postModel)
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].message.should.match(/isUUID/);
|
||||
});
|
||||
});
|
||||
|
||||
it('date is null', function () {
|
||||
const postModel = models.Post.forge({id: ObjectId().toHexString(), created_at: null});
|
||||
|
||||
postModel.changed = {created_at: postModel.get('updated_at')};
|
||||
|
||||
return validation.validateSchema('posts', postModel)
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].message.should.match(/posts\.created_at/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Assert the Validator dependency', function () {
|
||||
const validator = validation.validator;
|
||||
|
||||
it('isEmptyOrUrl filters javascript urls', function () {
|
||||
validator.isEmptyOrURL('javascript:alert(0)').should.be.false();
|
||||
validator.isEmptyOrURL('http://example.com/lol/<script>lalala</script>/').should.be.false();
|
||||
validator.isEmptyOrURL('http://example.com/lol?somequery=<script>lalala</script>').should.be.false();
|
||||
validator.isEmptyOrURL('').should.be.true();
|
||||
validator.isEmptyOrURL('http://localhost:2368').should.be.true();
|
||||
validator.isEmptyOrURL('http://example.com/test/').should.be.true();
|
||||
validator.isEmptyOrURL('http://www.example.com/test/').should.be.true();
|
||||
validator.isEmptyOrURL('http://example.com/foo?somequery=bar').should.be.true();
|
||||
validator.isEmptyOrURL('example.com/test/').should.be.true();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
150
test/unit/data/validation/schema_spec.js
Normal file
150
test/unit/data/validation/schema_spec.js
Normal file
@ -0,0 +1,150 @@
|
||||
const should = require('should');
|
||||
const _ = require('lodash');
|
||||
const ObjectId = require('bson-objectid');
|
||||
const testUtils = require('../../../utils');
|
||||
const models = require('../../../../core/server/models');
|
||||
const validation = require('../../../../core/server/data/validation');
|
||||
|
||||
describe('Validate Schema', function () {
|
||||
before(function () {
|
||||
models.init();
|
||||
});
|
||||
|
||||
describe('models.add', function () {
|
||||
it('blank model', function () {
|
||||
// NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level.
|
||||
return validation.validateSchema('posts', models.Post.forge(), {method: 'insert'})
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(7);
|
||||
|
||||
const errorMessages = _.map(err, function (object) {
|
||||
return object.message;
|
||||
}).join(',');
|
||||
|
||||
// NOTE: Some of these fields are auto-filled in the model layer (e.g. author_id, created_at etc.)
|
||||
['id', 'uuid', 'slug', 'title', 'author_id', 'created_at', 'created_by'].forEach(function (attr) {
|
||||
errorMessages.should.match(new RegExp('posts.' + attr));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('blank id', function () {
|
||||
const postModel = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({
|
||||
id: null,
|
||||
slug: 'test'
|
||||
}));
|
||||
|
||||
return validation.validateSchema('posts', postModel, {method: 'insert'})
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].message.should.match(/posts\.id/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass', function () {
|
||||
return validation.validateSchema(
|
||||
'posts',
|
||||
models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'title'})),
|
||||
{method: 'insert'}
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms 0 and 1', function () {
|
||||
const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: 0}));
|
||||
post.get('featured').should.eql(0);
|
||||
|
||||
return validation.validateSchema('posts', post, {method: 'insert'})
|
||||
.then(function () {
|
||||
post.get('featured').should.eql(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps true or false', function () {
|
||||
const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: true}));
|
||||
post.get('featured').should.eql(true);
|
||||
|
||||
return validation.validateSchema('posts', post, {method: 'insert'})
|
||||
.then(function () {
|
||||
post.get('featured').should.eql(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhooks.add', function () {
|
||||
it('event name is not lowercase', function () {
|
||||
const webhook = models.Webhook.forge(testUtils.DataGenerator.forKnex.createWebhook({
|
||||
event: 'Test',
|
||||
integration_id: testUtils.DataGenerator.Content.integrations[0].id
|
||||
}));
|
||||
|
||||
// NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level.
|
||||
return validation.validateSchema('webhooks', webhook, {method: 'insert'})
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].errorType.should.eql('ValidationError');
|
||||
err[0].message.should.match(/isLowercase/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('models.edit', function () {
|
||||
it('uuid is invalid', function () {
|
||||
const postModel = models.Post.forge({id: ObjectId().toHexString(), uuid: '1234'});
|
||||
|
||||
postModel.changed = {uuid: postModel.get('uuid')};
|
||||
|
||||
return validation.validateSchema('posts', postModel)
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].message.should.match(/isUUID/);
|
||||
});
|
||||
});
|
||||
|
||||
it('date is null', function () {
|
||||
const postModel = models.Post.forge({id: ObjectId().toHexString(), created_at: null});
|
||||
|
||||
postModel.changed = {created_at: postModel.get('updated_at')};
|
||||
|
||||
return validation.validateSchema('posts', postModel)
|
||||
.then(function () {
|
||||
throw new Error('Expected ValidationError.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (!_.isArray(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
err.length.should.eql(1);
|
||||
err[0].message.should.match(/posts\.created_at/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
19
test/unit/data/validation/validator_spec.js
Normal file
19
test/unit/data/validation/validator_spec.js
Normal file
@ -0,0 +1,19 @@
|
||||
const should = require('should');
|
||||
|
||||
const validation = require('../../../../core/server/data/validation');
|
||||
|
||||
describe('Validator dependency', function () {
|
||||
const validator = validation.validator;
|
||||
|
||||
it('isEmptyOrUrl filters javascript urls', function () {
|
||||
validator.isEmptyOrURL('javascript:alert(0)').should.be.false();
|
||||
validator.isEmptyOrURL('http://example.com/lol/<script>lalala</script>/').should.be.false();
|
||||
validator.isEmptyOrURL('http://example.com/lol?somequery=<script>lalala</script>').should.be.false();
|
||||
validator.isEmptyOrURL('').should.be.true();
|
||||
validator.isEmptyOrURL('http://localhost:2368').should.be.true();
|
||||
validator.isEmptyOrURL('http://example.com/test/').should.be.true();
|
||||
validator.isEmptyOrURL('http://www.example.com/test/').should.be.true();
|
||||
validator.isEmptyOrURL('http://example.com/foo?somequery=bar').should.be.true();
|
||||
validator.isEmptyOrURL('example.com/test/').should.be.true();
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user