0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-17 23:44:39 -05:00

Merged security files and history from TryGhost/Ghost

* included commits:
  Updated var declarations to const/let and no lists
  Move tests from core to root (#11700)
  Updated to use slugify method from SDK for safe string
  Added Node v10 Support (#10058)
  Dynamic Routing: Added migration for routes.yaml file (#9692)
  Fixed missing Bluebird require in `security/password.js` (#9624)
  🔥  Drop Node v4 Support
  Added unit tests for models.Invite.add
  Added lib.security.password lib
  Moved unique identifier generation to lib/security
  Moved tokens, url safe and safe string utility to lib/security
This commit is contained in:
Daniel Lockyer 2020-08-11 13:30:09 +01:00
commit ff9e980fcb
9 changed files with 467 additions and 0 deletions

View file

@ -0,0 +1,26 @@
let _private = {};
// @TODO: replace with crypto.randomBytes
_private.getRandomInt = function (min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
/**
* Return a unique identifier with the given `len`.
*
* @param {Number} maxLength
* @return {String}
* @api private
*/
module.exports.uid = function uid(maxLength) {
const buf = [];
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charLength = chars.length;
let i;
for (i = 0; i < maxLength; i = i + 1) {
buf.push(chars[_private.getRandomInt(0, charLength - 1)]);
}
return buf.join('');
};

View file

@ -0,0 +1,21 @@
module.exports = {
get url() {
return require('./url');
},
get tokens() {
return require('./tokens');
},
get string() {
return require('./string');
},
get identifier() {
return require('./identifier');
},
get password() {
return require('./password');
}
};

View file

@ -0,0 +1,18 @@
const Promise = require('bluebird');
module.exports.hash = function hash(plainPassword) {
const bcrypt = require('bcryptjs');
const bcryptGenSalt = Promise.promisify(bcrypt.genSalt);
const bcryptHash = Promise.promisify(bcrypt.hash);
return bcryptGenSalt().then(function (salt) {
return bcryptHash(plainPassword, salt);
});
};
module.exports.compare = function compare(plainPassword, hashedPassword) {
const bcrypt = require('bcryptjs');
const bcryptCompare = Promise.promisify(bcrypt.compare);
return bcryptCompare(plainPassword, hashedPassword);
};

View file

@ -0,0 +1,11 @@
const _ = require('lodash');
const slugify = require('@tryghost/string').slugify;
module.exports.safe = function safe(string, options) {
options = options || {};
let opts = {requiredChangesOnly: true};
if (!_.has(options, 'importing') || !options.importing) {
opts.requiredChangesOnly = false;
}
return slugify(string, opts);
};

View file

@ -0,0 +1,115 @@
const crypto = require('crypto');
module.exports.generateFromContent = function generateFromContent(options) {
options = options || {};
const hash = crypto.createHash('sha256');
const content = options.content;
let text = '';
hash.update(content);
text += [content, hash.digest('base64')].join('|');
return Buffer.from(text).toString('base64');
};
module.exports.generateFromEmail = function generateFromEmail(options) {
options = options || {};
const hash = crypto.createHash('sha256');
const expires = options.expires;
const email = options.email;
const secret = options.secret;
let text = '';
hash.update(String(expires));
hash.update(email.toLocaleLowerCase());
hash.update(String(secret));
text += [expires, email, hash.digest('base64')].join('|');
return Buffer.from(text).toString('base64');
};
module.exports.resetToken = {
generateHash: function generateHash(options) {
options = options || {};
const hash = crypto.createHash('sha256');
const expires = options.expires;
const email = options.email;
const dbHash = options.dbHash;
const password = options.password;
let text = '';
hash.update(String(expires));
hash.update(email.toLocaleLowerCase());
hash.update(password);
hash.update(String(dbHash));
text += [expires, email, hash.digest('base64')].join('|');
return Buffer.from(text).toString('base64');
},
extract: function extract(options) {
options = options || {};
const token = options.token;
const tokenText = Buffer.from(token, 'base64').toString('ascii');
let parts;
let expires;
let email;
parts = tokenText.split('|');
// Check if invalid structure
if (!parts || parts.length !== 3) {
return false;
}
expires = parseInt(parts[0], 10);
email = parts[1];
return {
expires: expires,
email: email
};
},
compare: function compare(options) {
options = options || {};
const tokenToCompare = options.token;
const parts = exports.resetToken.extract({token: tokenToCompare});
const dbHash = options.dbHash;
const password = options.password;
let generatedToken;
let diff = 0;
let i;
if (isNaN(parts.expires)) {
return false;
}
// Check if token is expired to prevent replay attacks
if (parts.expires < Date.now()) {
return false;
}
generatedToken = exports.resetToken.generateHash({
email: parts.email,
expires: parts.expires,
dbHash: dbHash,
password: password
});
if (tokenToCompare.length !== generatedToken.length) {
diff = 1;
}
for (i = tokenToCompare.length - 1; i >= 0; i = i - 1) {
diff |= tokenToCompare.charCodeAt(i) ^ generatedToken.charCodeAt(i);
}
return diff === 0;
}
};

14
ghost/security/lib/url.js Normal file
View file

@ -0,0 +1,14 @@
// The token is encoded URL safe by replacing '+' with '-', '\' with '_' and removing '='
// NOTE: the token is not encoded using valid base64 anymore
module.exports.encodeBase64 = function encodeBase64(base64String) {
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
// Decode url safe base64 encoding and add padding ('=')
module.exports.decodeBase64 = function decodeBase64(base64String) {
base64String = base64String.replace(/-/g, '+').replace(/_/g, '/');
while (base64String.length % 4) {
base64String += '=';
}
return base64String;
};

View file

@ -0,0 +1,18 @@
const should = require('should');
const security = require('../../../../core/server/lib/security');
describe('Lib: Security - Password', function () {
it('hash plain password', function () {
return security.password.hash('test')
.then(function (hash) {
hash.should.match(/^\$2[ayb]\$.{56}$/);
});
});
it('compare password', function () {
return security.password.compare('test', '$2a$10$we16f8rpbrFZ34xWj0/ZC.LTPUux8ler7bcdTs5qIleN6srRHhilG')
.then(function (valid) {
valid.should.be.true;
});
});
});

View file

@ -0,0 +1,94 @@
const should = require('should');
const security = require('../../../../core/server/lib/security');
describe('Lib: Security - String', function () {
describe('Safe String', function () {
const options = {};
it('should remove beginning and ending whitespace', function () {
const result = security.string.safe(' stringwithspace ', options);
result.should.equal('stringwithspace');
});
it('can handle null strings', function () {
const result = security.string.safe(null);
result.should.equal('');
});
it('should remove non ascii characters', function () {
const result = security.string.safe('howtowin✓', options);
result.should.equal('howtowin');
});
it('should replace spaces with dashes', function () {
const result = security.string.safe('how to win', options);
result.should.equal('how-to-win');
});
it('should replace most special characters with dashes', function () {
const result = security.string.safe('a:b/c?d#e[f]g!h$i&j(k)l*m+n,o;{p}=q\\r%s<t>u|v^w~x£y"z@1.2`3', options);
result.should.equal('a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-1-2-3');
});
it('should replace all of the html4 compat symbols in ascii except hyphen and underscore', function () {
// note: This is missing the soft-hyphen char that isn't much-liked by linters/browsers/etc,
// it passed the test before it was removed
const result = security.string.safe('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿');
result.should.equal('_-c-y-ss-c-a-r-deg-23up-1o-1-41-23-4');
});
it('should replace all of the foreign chars in ascii', function () {
const result = security.string.safe('ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ');
result.should.equal('aaaaaaaeceeeeiiiidnoooooxouuuuuthssaaaaaaaeceeeeiiiidnooooo-ouuuuythy');
});
it('should remove special characters at the beginning of a string', function () {
const result = security.string.safe('.Not special', options);
result.should.equal('not-special');
});
it('should remove apostrophes ', function () {
const result = security.string.safe('how we shouldn\'t be', options);
result.should.equal('how-we-shouldnt-be');
});
it('should convert to lowercase', function () {
const result = security.string.safe('This has Upper Case', options);
result.should.equal('this-has-upper-case');
});
it('should convert multiple dashes into a single dash', function () {
const result = security.string.safe('This :) means everything', options);
result.should.equal('this-means-everything');
});
it('should remove trailing dashes from the result', function () {
const result = security.string.safe('This.', options);
result.should.equal('this');
});
it('should handle pound signs', function () {
const result = security.string.safe('WHOOPS! I spent all my £ again!', options);
result.should.equal('whoops-i-spent-all-my-again');
});
it('should properly handle unicode punctuation conversion', function () {
const result = security.string.safe('に間違いがないか、再度確認してください。再読み込みしてください。', options);
result.should.equal('nijian-wei-iganaika-zai-du-que-ren-sitekudasai-zai-du-miip-misitekudasai');
});
it('should not lose or convert dashes if options are passed with truthy importing flag', function () {
let result;
const options = {importing: true};
result = security.string.safe('-slug-with-starting-ending-and---multiple-dashes-', options);
result.should.equal('-slug-with-starting-ending-and---multiple-dashes-');
});
it('should still remove/convert invalid characters when passed options with truthy importing flag', function () {
let result;
const options = {importing: true};
result = security.string.safe('-slug-&with-✓-invalid-characters-に\'', options);
result.should.equal('-slug--with--invalid-characters-ni');
});
});
});

View file

@ -0,0 +1,150 @@
const should = require('should');
const uuid = require('uuid');
const security = require('../../../../core/server/lib/security');
describe('Utils: tokens', function () {
it('generate', function () {
const expires = Date.now() + 60 * 1000;
const dbHash = uuid.v4();
let token;
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: expires,
password: 'password',
dbHash: dbHash
});
should.exist(token);
token.length.should.be.above(0);
});
it('compare: success', function () {
const expires = Date.now() + 60 * 1000;
const dbHash = uuid.v4();
let token;
let tokenIsCorrect;
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: expires,
password: '12345678',
dbHash: dbHash
});
tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '12345678'
});
tokenIsCorrect.should.eql(true);
});
it('compare: error', function () {
const expires = Date.now() + 60 * 1000;
const dbHash = uuid.v4();
let token;
let tokenIsCorrect;
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: expires,
password: '12345678',
dbHash: dbHash
});
tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '123456'
});
tokenIsCorrect.should.eql(false);
});
it('extract', function () {
const expires = Date.now() + 60 * 1000;
const dbHash = uuid.v4();
let token;
let parts;
const email = 'test1@ghost.org';
token = security.tokens.resetToken.generateHash({
email: email,
expires: expires,
password: '12345678',
dbHash: dbHash
});
parts = security.tokens.resetToken.extract({
token: token
});
parts.email.should.eql(email);
parts.expires.should.eql(expires);
should.not.exist(parts.password);
should.not.exist(parts.dbHash);
});
it('extract', function () {
const expires = Date.now() + 60 * 1000;
const dbHash = uuid.v4();
let token;
let parts;
const email = 'test3@ghost.org';
token = security.tokens.resetToken.generateHash({
email: email,
expires: expires,
password: '$2a$10$t5dY1uRRdjvqfNlXhae3uuc0nuhi.Rd7/K/9JaHHwSkLm6UUa3NsW',
dbHash: dbHash
});
parts = security.tokens.resetToken.extract({
token: token
});
parts.email.should.eql(email);
parts.expires.should.eql(expires);
should.not.exist(parts.password);
should.not.exist(parts.dbHash);
});
it('can validate an URI encoded reset token', function () {
const expires = Date.now() + 60 * 1000;
const email = 'test1@ghost.org';
const dbHash = uuid.v4();
let token;
let tokenIsCorrect;
let parts;
token = security.tokens.resetToken.generateHash({
email: email,
expires: expires,
password: '12345678',
dbHash: dbHash
});
token = security.url.encodeBase64(token);
token = encodeURIComponent(token);
token = decodeURIComponent(token);
token = security.url.decodeBase64(token);
parts = security.tokens.resetToken.extract({
token: token
});
parts.email.should.eql(email);
parts.expires.should.eql(expires);
tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '12345678'
});
tokenIsCorrect.should.eql(true);
});
});