Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .secretlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"/mynewfancypasswordwhichisnotallowed/",
"/scriptTag\\.dataset\\.key/",
"/69010382388f9de5869ad6e558/",
"/process\\.env\\./"
"/process\\.env\\./",
"/this\\._settingsCache\\.get/"
]
},
{
Expand Down
63 changes: 5 additions & 58 deletions ghost/core/core/frontend/apps/private-blogging/lib/middleware.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
const fs = require('fs-extra');
const session = require('cookie-session');
const crypto = require('crypto');
const path = require('path');
const config = require('../../../../shared/config');
const urlUtils = require('../../../../shared/url-utils').default;
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const settingsCache = require('../../../../shared/settings-cache');
const privateSiteAccess = require('../../../../shared/private-site-access');
// routeKeywords.private: 'private'
const privateRoute = '/private/';

Expand All @@ -15,27 +14,6 @@ const messages = {
wrongAccessCode: 'Incorrect access code.'
};

function getAccessCode() {
const accessCode = settingsCache.get('password');
return typeof accessCode === 'string' ? accessCode : '';
}

function hasAccessCode(accessCode) {
return typeof accessCode === 'string' && accessCode.trim().length > 0;
}

function verifySessionHash(salt, hash) {
const accessCode = getAccessCode();

if (!salt || !hash || !hasAccessCode(accessCode)) {
return false;
}

let hasher = crypto.createHash('sha256');
hasher.update(accessCode + salt, 'utf8');
return hasher.digest('hex') === hash;
}

function getRedirectUrl(query) {
try {
const redirect = decodeURIComponent(query.r || '/');
Expand All @@ -57,11 +35,7 @@ function getRedirectUrl(query) {
}

function authenticatePrivateSession(req, res, next) {
const hash = req.session.token || '';
const salt = req.session.salt || '';
const isVerified = verifySessionHash(salt, hash);

if (isVerified) {
if (privateSiteAccess.hasAccess(req)) {
return next();
} else {
let redirectUrl = urlUtils.urlFor({relativeUrl: privateRoute});
Expand All @@ -72,23 +46,7 @@ function authenticatePrivateSession(req, res, next) {
}

const privateBlogging = {
checkIsPrivate: function checkIsPrivate(req, res, next) {
let isPrivateBlog = settingsCache.get('is_private');

if (!isPrivateBlog) {
res.isPrivateBlog = false;
return next();
}

res.isPrivateBlog = true;

return session({
name: 'ghost-private',
maxAge: (30 * 24 * 60 * 60 * 1000), // 30 days in ms
signed: false,
sameSite: 'none'
})(req, res, next);
},
checkIsPrivate: privateSiteAccess.loadSession,

filterPrivateRoutes: function filterPrivateRoutes(req, res, next) {
// If this site is not in private mode, skip
Expand Down Expand Up @@ -145,11 +103,7 @@ const privateBlogging = {
return res.redirect(urlUtils.urlFor('home', true));
}

const hash = req.session.token || '';
const salt = req.session.salt || '';
const isVerified = verifySessionHash(salt, hash);

if (isVerified) {
if (privateSiteAccess.hasAccess(req)) {
// redirect to home if user is already authenticated
return res.redirect(urlUtils.urlFor('home', true));
} else {
Expand All @@ -164,16 +118,9 @@ const privateBlogging = {
}

const submittedAccessCode = req.body && req.body.password;
const accessCode = getAccessCode();
const hasher = crypto.createHash('sha256');
const salt = Date.now().toString();
const forward = getRedirectUrl(req.query);

if (hasAccessCode(accessCode) && hasAccessCode(submittedAccessCode) && accessCode === submittedAccessCode) {
hasher.update(submittedAccessCode + salt, 'utf8');
req.session.token = hasher.digest('hex');
req.session.salt = salt;

if (privateSiteAccess.grantAccess(req, submittedAccessCode)) {
return res.redirect(urlUtils.urlFor({relativeUrl: forward}));
} else {
res.error = {
Expand Down
14 changes: 3 additions & 11 deletions ghost/core/core/server/api/endpoints/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,20 +226,12 @@ const controller = {
method: 'destroy'
},
async query(frame) {
const pagesToDelete = await models.Post.findAll({
filter: frame.options.filter,
status: 'all',
columns: ['status']
});

const allDraft = pagesToDelete.length > 0 && pagesToDelete.every((page) => {
return page.get('status') === 'draft';
});
if (allDraft) {
const result = await postsService.bulkDestroy(frame.options);
if (result.allDraft) {
frame.setHeader('X-Cache-Invalidate', '');
}

return await postsService.bulkDestroy(frame.options);
return result;
}
},

Expand Down
14 changes: 3 additions & 11 deletions ghost/core/core/server/api/endpoints/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,20 +303,12 @@ const controller = {
method: 'destroy'
},
async query(frame) {
const postsToDelete = await models.Post.findAll({
filter: frame.options.filter,
status: 'all',
columns: ['status']
});

const allDraft = postsToDelete.length > 0 && postsToDelete.every((post) => {
return post.get('status') === 'draft';
});
if (allDraft) {
const result = await postsService.bulkDestroy(frame.options);
if (result.allDraft) {
frame.setHeader('X-Cache-Invalidate', '');
}

return await postsService.bulkDestroy(frame.options);
return result;
}
},

Expand Down
15 changes: 14 additions & 1 deletion ghost/core/core/server/api/endpoints/utils/api-filter-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {rejectStatements} from '@tryghost/mongo-utils';
import errors from '@tryghost/errors';
import {getUsedKeys, rejectStatements} from '@tryghost/mongo-utils';

const CONTENT_API_RESTRICTED_FIELDS = new Set([
'password',
Expand All @@ -20,3 +21,15 @@ export const rejectContentApiRestrictedFieldsTransformer = (input: unknown) => {
export const rejectAdminApiRestrictedFieldsTransformer = (input: unknown) => {
return rejectStatements(input, (key: string) => hasRestrictedSegment(key, ADMIN_API_RESTRICTED_FIELDS));
};

export const validateAdminApiBulkFilterTransformer = (input: unknown) => {
const restrictedField = getUsedKeys(input).find((key: string) => hasRestrictedSegment(key, ADMIN_API_RESTRICTED_FIELDS));

if (restrictedField) {
throw new errors.BadRequestError({
message: 'Restricted fields cannot be used in bulk operation filters.'
});
}

return input;
};
15 changes: 12 additions & 3 deletions ghost/core/core/server/models/settings.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const _ = require('lodash');
const crypto = require('crypto');
const keypair = require('keypair');
const ObjectID = require('bson-objectid').default;
const ghostBookshelf = require('./base');
const tpl = require('@tryghost/tpl');
Expand All @@ -22,11 +21,21 @@ let defaultSettings;

const doBlock = fn => fn();

// PKCS#1 PEM, matching the format previously generated by `keypair`
const generateKeypair = () => {
const {publicKey, privateKey} = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {type: 'pkcs1', format: 'pem'},
privateKeyEncoding: {type: 'pkcs1', format: 'pem'}
});
return {public: publicKey, private: privateKey};
};

const getMembersKey = doBlock(() => {
let UNO_KEYPAIRINO;
return function getKey(type) {
if (!UNO_KEYPAIRINO) {
UNO_KEYPAIRINO = keypair({bits: 1024});
UNO_KEYPAIRINO = generateKeypair();
}
return UNO_KEYPAIRINO[type];
};
Expand All @@ -36,7 +45,7 @@ const getGhostKey = doBlock(() => {
let UNO_KEYPAIRINO;
return function getKey(type) {
if (!UNO_KEYPAIRINO) {
UNO_KEYPAIRINO = keypair({bits: 1024});
UNO_KEYPAIRINO = generateKeypair();
}
return UNO_KEYPAIRINO[type];
};
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/core/server/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,7 @@ User = ghostBookshelf.Model.extend({
});
}

const updatedUser = await user.save({password: newPassword});
const updatedUser = await user.save({password: newPassword}, options);

// Destroy every active session for this user. The caller must mint a
// fresh session (with a new session_id) for self password-changes so
Expand Down
98 changes: 47 additions & 51 deletions ghost/core/core/server/services/auth/passwordreset.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const tpl = require('@tryghost/tpl');
const moment = require('moment');

const models = require('../../models');
const ghostBookshelf = require('../../models/base');
const urlUtils = require('../../../shared/url-utils').default;
const mail = require('../mail');

Expand Down Expand Up @@ -77,67 +78,62 @@ function extractTokenParts(options) {
}

function doReset(options, tokenParts, settingsAPI) {
let dbHash;

const data = options.data.password_reset[0];
const resetToken = data.token;
const oldPassword = data.oldPassword;
const newPassword = data.newPassword;

return settingsAPI.read(_.merge({key: 'db_hash'}, _.omit(options, 'data')))
.then((response) => {
dbHash = response.settings[0].value;
return ghostBookshelf.transaction(async (transacting) => {
const txOptions = {context: options.context, transacting};

return models.User.getByEmail(tokenParts.email, options);
})
.then((user) => {
if (!user) {
throw new errors.NotFoundError({message: tpl(messages.userNotFound)});
}
const settingsResponse = await settingsAPI.read({key: 'db_hash', ...txOptions});
const dbHash = settingsResponse.settings[0].value;

let compareResult = security.tokens.resetToken.compare({
token: resetToken,
dbHash: dbHash,
password: user.get('password')
});
const candidate = await models.User.getByEmail(tokenParts.email, txOptions);
if (!candidate) {
throw new errors.NotFoundError({message: tpl(messages.userNotFound)});
}

if (!compareResult.correct) {
let error;
if (compareResult.reason === 'expired' || compareResult.reason === 'invalid_expiry') {
error = new errors.BadRequestError({
message: tpl(messages.expired.message),
context: tpl(messages.expired.context),
help: tpl(messages.expired.help)
});
} else {
error = new errors.BadRequestError({
message: tpl(messages.invalidToken.message),
context: tpl(messages.invalidToken.context),
help: tpl(messages.invalidToken.help)
});
}

return Promise.reject(error);
}
const user = await new models.User({id: candidate.id})
.fetch({...txOptions, forUpdate: true, require: true});

return models.User.changePassword({
oldPassword: oldPassword,
newPassword: newPassword,
user_id: user.id
}, options);
})
.then((updatedUser) => {
updatedUser.set('status', 'active');
return updatedUser.save(options);
}).then((savedUser) => {
return {user: savedUser};
})
.catch((err) => {
if (errors.utils.isGhostError(err)) {
return Promise.reject(err);
}
return Promise.reject(new errors.UnauthorizedError({err: err}));
let compareResult = security.tokens.resetToken.compare({
token: resetToken,
dbHash: dbHash,
password: user.get('password')
});

if (!compareResult.correct) {
if (compareResult.reason === 'expired' || compareResult.reason === 'invalid_expiry') {
throw new errors.BadRequestError({
message: tpl(messages.expired.message),
context: tpl(messages.expired.context),
help: tpl(messages.expired.help)
});
} else {
throw new errors.BadRequestError({
message: tpl(messages.invalidToken.message),
context: tpl(messages.invalidToken.context),
help: tpl(messages.invalidToken.help)
});
}
}

const updatedUser = await models.User.changePassword({
oldPassword: oldPassword,
newPassword: newPassword,
user_id: user.id
}, txOptions);

updatedUser.set('status', 'active');
const savedUser = await updatedUser.save(null, txOptions);
return {user: savedUser};
}).catch((err) => {
if (errors.utils.isGhostError(err)) {
return Promise.reject(err);
}
return Promise.reject(new errors.UnauthorizedError({err: err}));
});
}

async function sendResetNotification(data, mailAPI) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
const logging = require('@tryghost/logging');

const EmailDeliveredEvent = require('./events/email-delivered-event');
const EmailOpenedEvent = require('./events/email-opened-event');
const EmailBouncedEvent = require('./events/email-bounced-event');
const EmailTemporaryBouncedEvent = require('./events/email-temporary-bounced-event');
const EmailUnsubscribedEvent = require('./events/email-unsubscribed-event');
const SpamComplaintEvent = require('./events/spam-complaint-event');
const {EmailDeliveredEvent} = require('./events/email-delivered-event');
const {EmailOpenedEvent} = require('./events/email-opened-event');
const {EmailBouncedEvent} = require('./events/email-bounced-event');
const {EmailTemporaryBouncedEvent} = require('./events/email-temporary-bounced-event');
const {EmailUnsubscribedEvent} = require('./events/email-unsubscribed-event');
const {SpamComplaintEvent} = require('./events/spam-complaint-event');

async function waitForEvent() {
return new Promise((resolve) => {
Expand Down
Loading
Loading