diff --git a/.secretlintrc.json b/.secretlintrc.json index e2e75f8b831..6bd9780e163 100644 --- a/.secretlintrc.json +++ b/.secretlintrc.json @@ -46,7 +46,8 @@ "/mynewfancypasswordwhichisnotallowed/", "/scriptTag\\.dataset\\.key/", "/69010382388f9de5869ad6e558/", - "/process\\.env\\./" + "/process\\.env\\./", + "/this\\._settingsCache\\.get/" ] }, { diff --git a/ghost/core/core/frontend/apps/private-blogging/lib/middleware.js b/ghost/core/core/frontend/apps/private-blogging/lib/middleware.js index 334bdb2bcd0..6162b772403 100644 --- a/ghost/core/core/frontend/apps/private-blogging/lib/middleware.js +++ b/ghost/core/core/frontend/apps/private-blogging/lib/middleware.js @@ -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/'; @@ -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 || '/'); @@ -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}); @@ -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 @@ -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 { @@ -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 = { diff --git a/ghost/core/core/server/api/endpoints/pages.js b/ghost/core/core/server/api/endpoints/pages.js index 2428d24112a..c62839582d5 100644 --- a/ghost/core/core/server/api/endpoints/pages.js +++ b/ghost/core/core/server/api/endpoints/pages.js @@ -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; } }, diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index c943413c1ab..c06d93435f5 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -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; } }, diff --git a/ghost/core/core/server/api/endpoints/utils/api-filter-utils.ts b/ghost/core/core/server/api/endpoints/utils/api-filter-utils.ts index 59aa1393470..e54384d4545 100644 --- a/ghost/core/core/server/api/endpoints/utils/api-filter-utils.ts +++ b/ghost/core/core/server/api/endpoints/utils/api-filter-utils.ts @@ -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', @@ -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; +}; diff --git a/ghost/core/core/server/models/settings.js b/ghost/core/core/server/models/settings.js index d6e67002a27..827785e040b 100644 --- a/ghost/core/core/server/models/settings.js +++ b/ghost/core/core/server/models/settings.js @@ -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'); @@ -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]; }; @@ -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]; }; diff --git a/ghost/core/core/server/models/user.js b/ghost/core/core/server/models/user.js index e97d222bc91..0151d639ff0 100644 --- a/ghost/core/core/server/models/user.js +++ b/ghost/core/core/server/models/user.js @@ -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 diff --git a/ghost/core/core/server/services/auth/passwordreset.js b/ghost/core/core/server/services/auth/passwordreset.js index f946882da2b..b96c0a25bbc 100644 --- a/ghost/core/core/server/services/auth/passwordreset.js +++ b/ghost/core/core/server/services/auth/passwordreset.js @@ -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'); @@ -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) { diff --git a/ghost/core/core/server/services/email-service/email-event-processor.js b/ghost/core/core/server/services/email-service/email-event-processor.js index 53f5bad9adb..23c56c0f967 100644 --- a/ghost/core/core/server/services/email-service/email-event-processor.js +++ b/ghost/core/core/server/services/email-service/email-event-processor.js @@ -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) => { diff --git a/ghost/core/core/server/services/email-service/events/email-bounced-event.js b/ghost/core/core/server/services/email-service/events/email-bounced-event.js deleted file mode 100644 index c6f2e179da2..00000000000 --- a/ghost/core/core/server/services/email-service/events/email-bounced-event.js +++ /dev/null @@ -1,63 +0,0 @@ -module.exports = class EmailBouncedEvent { - /** - * @readonly - * @type {string} - */ - id; - - /** - * @readonly - * @type {string} - */ - email; - - /** - * @readonly - * @type {string} - */ - memberId; - - /** - * @readonly - * @type {string} - */ - emailId; - - /** - * @readonly - * @type {{message: string, code: number, enhancedCode: string | null}|null} - */ - error; - - /** - * @readonly - * @type {string} - */ - emailRecipientId; - - /** - * @readonly - * @type {Date} - */ - timestamp; - - /** - * @private - */ - constructor({id, email, memberId, emailId, error, emailRecipientId, timestamp}) { - this.id = id; - this.memberId = memberId; - this.emailId = emailId; - this.email = email; - this.error = error; - this.emailRecipientId = emailRecipientId; - this.timestamp = timestamp; - } - - static create(data) { - return new EmailBouncedEvent({ - ...data, - timestamp: data.timestamp || new Date - }); - } -}; diff --git a/ghost/core/core/server/services/email-service/events/email-bounced-event.ts b/ghost/core/core/server/services/email-service/events/email-bounced-event.ts new file mode 100644 index 00000000000..18855dec745 --- /dev/null +++ b/ghost/core/core/server/services/email-service/events/email-bounced-event.ts @@ -0,0 +1,37 @@ +export type EmailFailureData = { + id?: string; + email: string; + memberId: string; + emailId: string; + error: { + message: string; + code: number; + enhancedCode?: string | null; + } | null; + emailRecipientId: string; + timestamp?: Date; +}; + +export class EmailBouncedEvent { + readonly id?: string; + readonly email: string; + readonly memberId: string; + readonly emailId: string; + readonly error: EmailFailureData['error']; + readonly emailRecipientId: string; + readonly timestamp: Date; + + private constructor({id, email, memberId, emailId, error, emailRecipientId, timestamp}: EmailFailureData & {timestamp: Date}) { + this.id = id; + this.memberId = memberId; + this.emailId = emailId; + this.email = email; + this.error = error; + this.emailRecipientId = emailRecipientId; + this.timestamp = timestamp; + } + + static create(data: EmailFailureData): EmailBouncedEvent { + return new EmailBouncedEvent({...data, timestamp: data.timestamp || new Date()}); + } +} diff --git a/ghost/core/core/server/services/email-service/events/email-delivered-event.js b/ghost/core/core/server/services/email-service/events/email-delivered-event.js deleted file mode 100644 index 4c07c3da89a..00000000000 --- a/ghost/core/core/server/services/email-service/events/email-delivered-event.js +++ /dev/null @@ -1,49 +0,0 @@ -module.exports = class EmailDeliveredEvent { - /** - * @readonly - * @type {string} - */ - email; - - /** - * @readonly - * @type {string} - */ - memberId; - - /** - * @readonly - * @type {string} - */ - emailId; - - /** - * @readonly - * @type {string} - */ - emailRecipientId; - - /** - * @readonly - * @type {Date} - */ - timestamp; - - /** - * @private - */ - constructor({email, memberId, emailId, emailRecipientId, timestamp}) { - this.email = email; - this.memberId = memberId; - this.emailId = emailId; - this.emailRecipientId = emailRecipientId; - this.timestamp = timestamp; - } - - static create(data) { - return new EmailDeliveredEvent({ - ...data, - timestamp: data.timestamp || new Date - }); - } -}; diff --git a/ghost/core/core/server/services/email-service/events/email-delivered-event.ts b/ghost/core/core/server/services/email-service/events/email-delivered-event.ts new file mode 100644 index 00000000000..e378f52cf20 --- /dev/null +++ b/ghost/core/core/server/services/email-service/events/email-delivered-event.ts @@ -0,0 +1,27 @@ +type EmailDeliveredEventData = { + email: string; + memberId: string; + emailId: string; + emailRecipientId: string; + timestamp?: Date; +}; + +export class EmailDeliveredEvent { + readonly email: string; + readonly memberId: string; + readonly emailId: string; + readonly emailRecipientId: string; + readonly timestamp: Date; + + private constructor({email, memberId, emailId, emailRecipientId, timestamp}: EmailDeliveredEventData & {timestamp: Date}) { + this.email = email; + this.memberId = memberId; + this.emailId = emailId; + this.emailRecipientId = emailRecipientId; + this.timestamp = timestamp; + } + + static create(data: EmailDeliveredEventData): EmailDeliveredEvent { + return new EmailDeliveredEvent({...data, timestamp: data.timestamp || new Date()}); + } +} diff --git a/ghost/core/core/server/services/email-service/events/email-opened-event.js b/ghost/core/core/server/services/email-service/events/email-opened-event.js deleted file mode 100644 index 2e531bd02ae..00000000000 --- a/ghost/core/core/server/services/email-service/events/email-opened-event.js +++ /dev/null @@ -1,49 +0,0 @@ -module.exports = class EmailOpenedEvent { - /** - * @readonly - * @type {string} - */ - email; - - /** - * @readonly - * @type {string} - */ - memberId; - - /** - * @readonly - * @type {string} - */ - emailId; - - /** - * @readonly - * @type {string} - */ - emailRecipientId; - - /** - * @readonly - * @type {Date} - */ - timestamp; - - /** - * @private - */ - constructor({email, memberId, emailId, emailRecipientId, timestamp}) { - this.memberId = memberId; - this.emailId = emailId; - this.emailRecipientId = emailRecipientId; - this.email = email; - this.timestamp = timestamp; - } - - static create(data) { - return new EmailOpenedEvent({ - ...data, - timestamp: data.timestamp || new Date - }); - } -}; diff --git a/ghost/core/core/server/services/email-service/events/email-opened-event.ts b/ghost/core/core/server/services/email-service/events/email-opened-event.ts new file mode 100644 index 00000000000..596cfd69a6c --- /dev/null +++ b/ghost/core/core/server/services/email-service/events/email-opened-event.ts @@ -0,0 +1,27 @@ +type EmailOpenedEventData = { + email: string; + memberId: string; + emailId: string; + emailRecipientId: string; + timestamp?: Date; +}; + +export class EmailOpenedEvent { + readonly email: string; + readonly memberId: string; + readonly emailId: string; + readonly emailRecipientId: string; + readonly timestamp: Date; + + private constructor({email, memberId, emailId, emailRecipientId, timestamp}: EmailOpenedEventData & {timestamp: Date}) { + this.memberId = memberId; + this.emailId = emailId; + this.emailRecipientId = emailRecipientId; + this.email = email; + this.timestamp = timestamp; + } + + static create(data: EmailOpenedEventData): EmailOpenedEvent { + return new EmailOpenedEvent({...data, timestamp: data.timestamp || new Date()}); + } +} diff --git a/ghost/core/core/server/services/email-service/events/email-temporary-bounced-event.js b/ghost/core/core/server/services/email-service/events/email-temporary-bounced-event.js deleted file mode 100644 index f8159038a4f..00000000000 --- a/ghost/core/core/server/services/email-service/events/email-temporary-bounced-event.js +++ /dev/null @@ -1,63 +0,0 @@ -module.exports = class EmailTemporaryBouncedEvent { - /** - * @readonly - * @type {string} - */ - id; - - /** - * @readonly - * @type {string} - */ - email; - - /** - * @readonly - * @type {string} - */ - memberId; - - /** - * @readonly - * @type {string} - */ - emailId; - - /** - * @readonly - * @type {{message: string, code: number, enhancedCode: string | null}|null} - */ - error; - - /** - * @readonly - * @type {string} - */ - emailRecipientId; - - /** - * @readonly - * @type {Date} - */ - timestamp; - - /** - * @private - */ - constructor({id, email, memberId, emailId, emailRecipientId, error, timestamp}) { - this.id = id; - this.memberId = memberId; - this.emailId = emailId; - this.email = email; - this.error = error; - this.emailRecipientId = emailRecipientId; - this.timestamp = timestamp; - } - - static create(data) { - return new EmailTemporaryBouncedEvent({ - ...data, - timestamp: data.timestamp || new Date - }); - } -}; diff --git a/ghost/core/core/server/services/email-service/events/email-temporary-bounced-event.ts b/ghost/core/core/server/services/email-service/events/email-temporary-bounced-event.ts new file mode 100644 index 00000000000..a0db32eb4a4 --- /dev/null +++ b/ghost/core/core/server/services/email-service/events/email-temporary-bounced-event.ts @@ -0,0 +1,25 @@ +import type {EmailFailureData} from './email-bounced-event'; + +export class EmailTemporaryBouncedEvent { + readonly id?: string; + readonly email: string; + readonly memberId: string; + readonly emailId: string; + readonly error: EmailFailureData['error']; + readonly emailRecipientId: string; + readonly timestamp: Date; + + private constructor({id, email, memberId, emailId, emailRecipientId, error, timestamp}: EmailFailureData & {timestamp: Date}) { + this.id = id; + this.memberId = memberId; + this.emailId = emailId; + this.email = email; + this.error = error; + this.emailRecipientId = emailRecipientId; + this.timestamp = timestamp; + } + + static create(data: EmailFailureData): EmailTemporaryBouncedEvent { + return new EmailTemporaryBouncedEvent({...data, timestamp: data.timestamp || new Date()}); + } +} diff --git a/ghost/core/core/server/services/email-service/events/email-unsubscribed-event.js b/ghost/core/core/server/services/email-service/events/email-unsubscribed-event.js deleted file mode 100644 index d024d9132b0..00000000000 --- a/ghost/core/core/server/services/email-service/events/email-unsubscribed-event.js +++ /dev/null @@ -1,42 +0,0 @@ -module.exports = class EmailUnsubscribedEvent { - /** - * @readonly - * @type {string} - */ - email; - - /** - * @readonly - * @type {string} - */ - memberId; - - /** - * @readonly - * @type {string} - */ - emailId; - - /** - * @readonly - * @type {Date} - */ - timestamp; - - /** - * @private - */ - constructor({email, memberId, emailId, timestamp}) { - this.memberId = memberId; - this.emailId = emailId; - this.email = email; - this.timestamp = timestamp; - } - - static create(data) { - return new EmailUnsubscribedEvent({ - ...data, - timestamp: data.timestamp || new Date - }); - } -}; diff --git a/ghost/core/core/server/services/email-service/events/email-unsubscribed-event.ts b/ghost/core/core/server/services/email-service/events/email-unsubscribed-event.ts new file mode 100644 index 00000000000..6f387b5ce40 --- /dev/null +++ b/ghost/core/core/server/services/email-service/events/email-unsubscribed-event.ts @@ -0,0 +1,24 @@ +type EmailUnsubscribedEventData = { + email: string; + memberId: string; + emailId: string; + timestamp?: Date; +}; + +export class EmailUnsubscribedEvent { + readonly email: string; + readonly memberId: string; + readonly emailId: string; + readonly timestamp: Date; + + private constructor({email, memberId, emailId, timestamp}: EmailUnsubscribedEventData & {timestamp: Date}) { + this.memberId = memberId; + this.emailId = emailId; + this.email = email; + this.timestamp = timestamp; + } + + static create(data: EmailUnsubscribedEventData): EmailUnsubscribedEvent { + return new EmailUnsubscribedEvent({...data, timestamp: data.timestamp || new Date()}); + } +} diff --git a/ghost/core/core/server/services/email-service/events/spam-complaint-event.js b/ghost/core/core/server/services/email-service/events/spam-complaint-event.js deleted file mode 100644 index ee195da6d2f..00000000000 --- a/ghost/core/core/server/services/email-service/events/spam-complaint-event.js +++ /dev/null @@ -1,42 +0,0 @@ -module.exports = class SpamComplaintEvent { - /** - * @readonly - * @type {string} - */ - email; - - /** - * @readonly - * @type {string} - */ - memberId; - - /** - * @readonly - * @type {string} - */ - emailId; - - /** - * @readonly - * @type {Date} - */ - timestamp; - - /** - * @private - */ - constructor({email, memberId, emailId, timestamp}) { - this.memberId = memberId; - this.emailId = emailId; - this.email = email; - this.timestamp = timestamp; - } - - static create(data) { - return new SpamComplaintEvent({ - ...data, - timestamp: data.timestamp || new Date - }); - } -}; diff --git a/ghost/core/core/server/services/email-service/events/spam-complaint-event.ts b/ghost/core/core/server/services/email-service/events/spam-complaint-event.ts new file mode 100644 index 00000000000..75250affa30 --- /dev/null +++ b/ghost/core/core/server/services/email-service/events/spam-complaint-event.ts @@ -0,0 +1,24 @@ +type SpamComplaintEventData = { + email: string; + memberId: string; + emailId: string; + timestamp?: Date; +}; + +export class SpamComplaintEvent { + readonly email: string; + readonly memberId: string; + readonly emailId: string; + readonly timestamp: Date; + + private constructor({email, memberId, emailId, timestamp}: SpamComplaintEventData & {timestamp: Date}) { + this.memberId = memberId; + this.emailId = emailId; + this.email = email; + this.timestamp = timestamp; + } + + static create(data: SpamComplaintEventData): SpamComplaintEvent { + return new SpamComplaintEvent({...data, timestamp: data.timestamp || new Date()}); + } +} diff --git a/ghost/core/core/server/services/email-service/newsletter-email-event-storage.js b/ghost/core/core/server/services/email-service/newsletter-email-event-storage.js index 06b81a8b0d3..40f804da683 100644 --- a/ghost/core/core/server/services/email-service/newsletter-email-event-storage.js +++ b/ghost/core/core/server/services/email-service/newsletter-email-event-storage.js @@ -120,7 +120,7 @@ class NewsletterEmailEventStorage { /** * @private * @param {'temporary'|'permanent'} severity - * @param {import('./events/email-temporary-bounced-event')|import('./events/email-bounced-event')} event + * @param {import('./events/email-temporary-bounced-event').EmailTemporaryBouncedEvent|import('./events/email-bounced-event').EmailBouncedEvent} event * @param {{transacting?: any}} options * @returns */ @@ -223,7 +223,7 @@ class NewsletterEmailEventStorage { */ /** - * @param {import('./events/email-unsubscribed-event')} event + * @param {import('./events/email-unsubscribed-event').EmailUnsubscribedEvent} event * @returns {Promise} */ async findNewslettersToKeep(event) { diff --git a/ghost/core/core/server/services/email-suppression-list/mailgun-email-suppression-list.js b/ghost/core/core/server/services/email-suppression-list/mailgun-email-suppression-list.js index 371bf7ecb12..beb9b50cbf9 100644 --- a/ghost/core/core/server/services/email-suppression-list/mailgun-email-suppression-list.js +++ b/ghost/core/core/server/services/email-suppression-list/mailgun-email-suppression-list.js @@ -1,6 +1,6 @@ const {AbstractEmailSuppressionList, EmailSuppressionData, EmailSuppressedEvent} = require('./email-suppression-list'); -const SpamComplaintEvent = require('../email-service/events/spam-complaint-event'); -const EmailBouncedEvent = require('../email-service/events/email-bounced-event'); +const {SpamComplaintEvent} = require('../email-service/events/spam-complaint-event'); +const {EmailBouncedEvent} = require('../email-service/events/email-bounced-event'); const DomainEvents = require('@tryghost/domain-events'); const logging = require('@tryghost/logging'); const models = require('../../models'); diff --git a/ghost/core/core/server/services/members-events/last-seen-at-updater.js b/ghost/core/core/server/services/members-events/last-seen-at-updater.js index b34adbe9546..0ba62375cee 100644 --- a/ghost/core/core/server/services/members-events/last-seen-at-updater.js +++ b/ghost/core/core/server/services/members-events/last-seen-at-updater.js @@ -1,7 +1,7 @@ const {MemberPageViewEvent, MemberCommentEvent, MemberLinkClickEvent} = require('../../../shared/events'); const moment = require('moment-timezone'); const {IncorrectUsageError} = require('@tryghost/errors'); -const EmailOpenedEvent = require('../email-service/events/email-opened-event'); +const {EmailOpenedEvent} = require('../email-service/events/email-opened-event'); const logging = require('@tryghost/logging'); const LastSeenAtCache = require('./last-seen-at-cache'); diff --git a/ghost/core/core/server/services/members/members-config-provider.js b/ghost/core/core/server/services/members/members-config-provider.js index c587ae6079d..1b0b0633acd 100644 --- a/ghost/core/core/server/services/members/members-config-provider.js +++ b/ghost/core/core/server/services/members/members-config-provider.js @@ -1,5 +1,15 @@ +const crypto = require('node:crypto'); const logging = require('@tryghost/logging'); -const createKeypair = require('keypair'); + +// 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}; +}; class MembersConfigProvider { /** @@ -54,7 +64,7 @@ class MembersConfigProvider { if (!privateKey || !publicKey) { logging.warn('Could not find members_private_key, using dynamically generated keypair'); - const keypair = createKeypair({bits: 1024}); + const keypair = generateKeypair(); privateKey = keypair.private; publicKey = keypair.public; } diff --git a/ghost/core/core/server/services/permissions/can-this.js b/ghost/core/core/server/services/permissions/can-this.js index cf9e4784965..5500bd1e59e 100644 --- a/ghost/core/core/server/services/permissions/can-this.js +++ b/ghost/core/core/server/services/permissions/can-this.js @@ -87,10 +87,13 @@ class CanThisResult { } } + // Ensure permission decisions are based on the user's role if present, not their staff-token. + const permissionsForModel = loadedPermissions.user ? {...loadedPermissions, apiKey: null} : loadedPermissions; + // Offer a chance for the TargetModel to override the results if (TargetModel && _.isFunction(TargetModel.permissible)) { return TargetModel.permissible( - modelId, actType, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission + modelId, actType, context, unsafeAttrs, permissionsForModel, hasUserPermission, hasApiKeyPermission ); } diff --git a/ghost/core/core/server/services/posts/posts-service.js b/ghost/core/core/server/services/posts/posts-service.js index a47117bcea4..2558d235247 100644 --- a/ghost/core/core/server/services/posts/posts-service.js +++ b/ghost/core/core/server/services/posts/posts-service.js @@ -6,6 +6,7 @@ const ObjectId = require('bson-objectid').default; const pick = require('lodash/pick'); const DomainEvents = require('@tryghost/domain-events'); const PostEmailHandler = require('./post-email-handler'); +const {validateAdminApiBulkFilterTransformer} = require('../../api/endpoints/utils/api-filter-utils'); const messages = { invalidVisibilityFilter: 'Invalid visibility filter.', @@ -27,6 +28,15 @@ class PostsService { this.postEmailHandler = new PostEmailHandler({models, emailService}); } + #getFilteredBulkPostQuery(options) { + return this.models.Post.getFilteredCollectionQuery({ + filter: options.filter, + status: 'all', + transacting: options.transacting, + mongoTransformer: validateAdminApiBulkFilterTransformer + }); + } + /** * * @param {Object} options - frame options @@ -215,11 +225,7 @@ class PostsService { } } - const postRows = await this.models.Post.getFilteredCollectionQuery({ - filter: options.filter, - status: 'all', - transacting: options.transacting - }).select('posts.id'); + const postRows = await this.#getFilteredBulkPostQuery(options).select('posts.id'); const postTags = data.tags.reduce((pt, tag) => { return pt.concat(postRows.map((post) => { @@ -245,7 +251,7 @@ class PostsService { /** * * @param {Object} options - * @returns Promise<{successful: number, unsuccessful: number, deleteIds: string[]}> + * @returns Promise<{successful: number, unsuccessful: number, deleteIds: string[], allDraft: boolean}> */ async #bulkDestroy(options) { if (!options.transacting) { @@ -257,12 +263,11 @@ class PostsService { }); } - const postRows = await this.models.Post.getFilteredCollectionQuery({ - filter: options.filter, - status: 'all', - transacting: options.transacting - }).leftJoin('emails', 'posts.id', 'emails.post_id').select('posts.id', 'emails.id as email_id'); + const postRows = await this.#getFilteredBulkPostQuery(options) + .leftJoin('emails', 'posts.id', 'emails.post_id') + .select('posts.id', 'posts.status', 'emails.id as email_id'); const deleteIds = postRows.map(row => row.id); + const allDraft = postRows.length > 0 && postRows.every(row => row.status === 'draft'); // We also need to collect the email ids because the email relation doesn't have cascase, and we need to delete the related relations of the post const deleteEmailIds = postRows.map(row => row.email_id).filter(id => !!id); @@ -328,6 +333,7 @@ class PostsService { const result = await this.models.Post.bulkDestroy(deleteIds, 'posts', {...options, throwErrors: true}); result.deleteIds = deleteIds; + result.allDraft = allDraft; return result; } @@ -354,11 +360,7 @@ class PostsService { }); } - const postRows = await this.models.Post.getFilteredCollectionQuery({ - filter: options.filter, - status: 'all', - transacting: options.transacting - }).select('posts.id'); + const postRows = await this.#getFilteredBulkPostQuery(options).select('posts.id'); const editIds = postRows.map(row => row.id); diff --git a/ghost/core/core/server/web/comments/routes.js b/ghost/core/core/server/web/comments/routes.js index 70cf29a4c5d..ed1f6ffef9c 100644 --- a/ghost/core/core/server/web/comments/routes.js +++ b/ghost/core/core/server/web/comments/routes.js @@ -8,9 +8,11 @@ const tpl = require('@tryghost/tpl'); const bodyParser = require('body-parser'); const membersService = require('../../../server/services/members'); +const privateSiteAccess = require('../../../shared/private-site-access'); const messages = { - memberCommentingDisabled: 'Your commenting ability has been disabled.' + memberCommentingDisabled: 'Your commenting ability has been disabled.', + privateSiteAccessRequired: 'Comment browsing is not available' }; /** @@ -27,29 +29,50 @@ function checkMemberCommenting(req, res, next) { next(); } +/** + * Middleware to reject comment read requests without a valid private-site + * session when the site is in private mode. + */ +function checkCanReadComments(req, res, next) { + if (res.isPrivateBlog && !privateSiteAccess.hasAccess(req)) { + return next(new errors.NoPermissionError({ + message: tpl(messages.privateSiteAccessRequired) + })); + } + next(); +} + /** * @returns {import('express').Router} */ module.exports = function apiRoutes() { const router = express.Router('comment api'); router.use(bodyParser.json({limit: '50mb'})); + router.use(privateSiteAccess.loadSession); - const countsCache = shared.middleware.cacheControl( + const publicCountsCache = shared.middleware.cacheControl( 'public', {maxAge: config.get('caching:commentsCountAPI:maxAge')} ); - router.get('/counts', countsCache, http(api.commentsMembers.counts)); + const privateCountsCache = shared.middleware.cacheControl('private'); + const countsCache = (req, res, next) => { + if (res.isPrivateBlog) { + return privateCountsCache(req, res, next); + } + return publicCountsCache(req, res, next); + }; + router.get('/counts', checkCanReadComments, countsCache, http(api.commentsMembers.counts)); - // Authenticated Routes + // Load the optional member session for member-specific comment state router.use(membersService.middleware.loadMemberSession); // Enforce capped limit parameter router.use(shared.middleware.maxLimitCap); - router.get('/', http(api.commentsMembers.browse)); - router.get('/post/:post_id', http(api.commentsMembers.browse)); - router.get('/:id', http(api.commentsMembers.read)); - router.get('/:id/replies', http(api.commentsMembers.replies)); + router.get('/', checkCanReadComments, http(api.commentsMembers.browse)); + router.get('/post/:post_id', checkCanReadComments, http(api.commentsMembers.browse)); + router.get('/:id', checkCanReadComments, http(api.commentsMembers.read)); + router.get('/:id/replies', checkCanReadComments, http(api.commentsMembers.replies)); // Write operations require member to have commenting ability enabled router.post('/', checkMemberCommenting, http(api.commentsMembers.add)); diff --git a/ghost/core/core/shared/private-site-access/index.js b/ghost/core/core/shared/private-site-access/index.js new file mode 100644 index 00000000000..0d813fa5c05 --- /dev/null +++ b/ghost/core/core/shared/private-site-access/index.js @@ -0,0 +1,68 @@ +const crypto = require('crypto'); +const session = require('cookie-session'); +const settingsCache = require('../settings-cache'); + +const privateSession = session({ + name: 'ghost-private', + maxAge: 30 * 24 * 60 * 60 * 1000, + signed: false, + sameSite: 'none' +}); + +function getAccessCode() { + const accessCode = settingsCache.get('password'); + return typeof accessCode === 'string' ? accessCode : ''; +} + +function hasAccessCode(accessCode) { + return typeof accessCode === 'string' && accessCode.trim().length > 0; +} + +function hashAccessCode(accessCode, salt) { + const hasher = crypto.createHash('sha256'); + hasher.update(accessCode + salt, 'utf8'); + return hasher.digest('hex'); +} + +function hasAccess(req) { + const accessCode = getAccessCode(); + const salt = req.session?.salt || ''; + const hash = req.session?.token || ''; + + if (!salt || !hash || !hasAccessCode(accessCode)) { + return false; + } + + return hashAccessCode(accessCode, salt) === hash; +} + +function grantAccess(req, submittedAccessCode) { + const accessCode = getAccessCode(); + + if (!hasAccessCode(accessCode) || !hasAccessCode(submittedAccessCode) || accessCode !== submittedAccessCode) { + return false; + } + + const salt = Date.now().toString(); + req.session.token = hashAccessCode(submittedAccessCode, salt); + req.session.salt = salt; + return true; +} + +function loadSession(req, res, next) { + const isPrivateBlog = settingsCache.get('is_private'); + + if (!isPrivateBlog) { + res.isPrivateBlog = false; + return next(); + } + + res.isPrivateBlog = true; + return privateSession(req, res, next); +} + +module.exports = { + grantAccess, + hasAccess, + loadSession +}; diff --git a/ghost/core/package.json b/ghost/core/package.json index 6028b28d8cc..52cc6b58f5c 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -191,7 +191,6 @@ "jsonc-parser": "catalog:", "jsonwebtoken": "8.5.1", "juice": "9.1.0", - "keypair": "1.0.4", "knex": "2.4.2", "knex-migrator": "catalog:", "leaky-bucket": "2.2.0", diff --git a/ghost/core/test/e2e-api/admin/pages-bulk.test.js b/ghost/core/test/e2e-api/admin/pages-bulk.test.js index 97a65ea8f7e..38d0ebcfd8a 100644 --- a/ghost/core/test/e2e-api/admin/pages-bulk.test.js +++ b/ghost/core/test/e2e-api/admin/pages-bulk.test.js @@ -290,6 +290,17 @@ describe('Pages Bulk API', function () { }); describe('Delete', function () { + it('Rejects restricted filter fields without broadening the operation', async function () { + const pagesBefore = await models.Post.findAll({filter: 'type:page', status: 'all'}); + + await agent + .delete('/pages/?filter=' + encodeURIComponent('authors.password:abcd')) + .expectStatus(400); + + const pagesAfter = await models.Post.findAll({filter: 'type:page', status: 'all'}); + assert.deepEqual(pagesAfter.pluck('id'), pagesBefore.pluck('id')); + }); + it('Can delete pages that match a tag', async function () { const tag = await models.Tag.findOne({id: fixtureManager.get('tags', 0).id}); const filter = 'tag:' + tag.get('slug'); diff --git a/ghost/core/test/e2e-api/admin/posts-bulk.test.js b/ghost/core/test/e2e-api/admin/posts-bulk.test.js index 8689d057d67..9488bf5ed08 100644 --- a/ghost/core/test/e2e-api/admin/posts-bulk.test.js +++ b/ghost/core/test/e2e-api/admin/posts-bulk.test.js @@ -30,6 +30,22 @@ describe('Posts Bulk API', function () { }); describe('Edit', function () { + it('Rejects restricted filter fields without broadening the operation', async function () { + const featuredBefore = await models.Post.findAll({filter: 'featured:true', status: 'all'}); + + await agent + .put('/posts/bulk/?filter=' + encodeURIComponent('authors.password:abcd')) + .body({ + bulk: { + action: 'feature' + } + }) + .expectStatus(400); + + const featuredAfter = await models.Post.findAll({filter: 'featured:true', status: 'all'}); + assert.deepEqual(featuredAfter.pluck('id'), featuredBefore.pluck('id')); + }); + it('Can feature multiple posts', async function () { const filter = 'status:[published,draft,scheduled,sent]'; diff --git a/ghost/core/test/e2e-api/members-comments/comments.test.js b/ghost/core/test/e2e-api/members-comments/comments.test.js index 666298e714f..2d1475c97b5 100644 --- a/ghost/core/test/e2e-api/members-comments/comments.test.js +++ b/ghost/core/test/e2e-api/members-comments/comments.test.js @@ -1,11 +1,14 @@ const assert = require('node:assert/strict'); -const {agentProvider, mockManager, fixtureManager, matchers, configUtils, dbUtils} = require('../../utils/e2e-framework'); +const {agentProvider, mockManager, fixtureManager, matchers, configUtils, dbUtils, cacheRules} = require('../../utils/e2e-framework'); const {nullable, anyEtag, anyObjectId, anyLocationFor, anyISODateTime, anyErrorId, anyUuid, anyNumber, anyBoolean, stringMatching} = matchers; const models = require('../../../core/server/models'); const moment = require('moment-timezone'); const settingsCache = require('../../../core/shared/settings-cache'); const sinon = require('sinon'); const DomainEvents = require('@tryghost/domain-events'); +const TestAgent = require('../../utils/agents/test-agent'); +const membersService = require('../../../core/server/services/members'); +const privateSiteAccess = require('../../../core/shared/private-site-access'); let membersAgent, membersAgent2, postId, postAuthorEmail, postTitle; let emailMockReceiver; @@ -2539,4 +2542,118 @@ describe('Comments API', function () { }); }); }); + + // Require private-site access for comment reads in private mode + describe('When site is in private mode', function () { + const privateAccessCode = 'private-comments-test'; + let comment; + let originalIsPrivateSetting; + let originalPasswordSetting; + + function createSiteAgent() { + return new TestAgent(membersAgent.app, { + apiURL: '', + originURL: configUtils.config.get('url') + }); + } + + async function loginAsMember(agent) { + const magicLink = await membersService.api.getMagicLink('member-any@example.com', 'signin'); + const token = new URL(magicLink).searchParams.get('token'); + await agent.get(`/members/?token=${token}`).expectStatus(302); + } + + function grantPrivateSiteAccess(agent) { + const req = {session: {}}; + assert.equal(privateSiteAccess.grantAccess(req, privateAccessCode), true); + + const sessionCookie = Buffer.from(JSON.stringify(req.session)).toString('base64'); + agent.jar.setCookies([`ghost-private=${sessionCookie}; path=/; httponly`]); + } + + function commentReadPaths() { + return [ + '/members/api/comments/counts', + '/members/api/comments', + `/members/api/comments/post/${postId}`, + `/members/api/comments/${comment.id}`, + `/members/api/comments/${comment.id}/replies` + ]; + } + + async function expectCommentReadStatus(agent, status) { + for (const path of commentReadPaths()) { + await agent.get(path).expectStatus(status); + } + } + + beforeAll(async function () { + await models.Post.edit({visibility: 'public'}, {id: postId}); + + originalIsPrivateSetting = settingsCache.get('is_private', {resolve: false}); + originalPasswordSetting = settingsCache.get('password', {resolve: false}); + }); + + beforeEach(async function () { + settingsCache.set('is_private', {...originalIsPrivateSetting, value: true}); + settingsCache.set('password', {...originalPasswordSetting, value: privateAccessCode}); + + comment = await dbFns.addComment({ + post_id: postId, + member_id: fixtureManager.get('members', 0).id + }); + }); + + afterAll(async function () { + settingsCache.set('is_private', originalIsPrivateSetting); + settingsCache.set('password', originalPasswordSetting); + }); + + it('Rejects anonymous visitors without private-site access', async function () { + await expectCommentReadStatus(createSiteAgent(), 403); + }); + + it('Does not treat a member session as private-site access', async function () { + const agent = createSiteAgent(); + await loginAsMember(agent); + await expectCommentReadStatus(agent, 403); + }); + + it('Allows visitors with private-site access', async function () { + const agent = createSiteAgent(); + grantPrivateSiteAccess(agent); + await expectCommentReadStatus(agent, 200); + }); + + it('Allows members with private-site access', async function () { + const agent = createSiteAgent(); + await loginAsMember(agent); + grantPrivateSiteAccess(agent); + await expectCommentReadStatus(agent, 200); + }); + + it('Invalidates private-site access when the access code changes', async function () { + const agent = createSiteAgent(); + grantPrivateSiteAccess(agent); + + settingsCache.set('password', {...originalPasswordSetting, value: 'changed-private-comments-test'}); + + await agent.get('/members/api/comments').expectStatus(403); + }); + + it('Prevents shared caching of private comment counts', async function () { + const agent = createSiteAgent(); + grantPrivateSiteAccess(agent); + + const response = await agent.get('/members/api/comments/counts').expectStatus(200); + assert.equal(response.headers['cache-control'], cacheRules.private); + }); + + it('Keeps public comment counts publicly cacheable', async function () { + settingsCache.set('is_private', {...originalIsPrivateSetting, value: false}); + + const response = await createSiteAgent().get('/members/api/comments/counts').expectStatus(200); + assert.match(response.headers['cache-control'], /^public, max-age=/); + }); + }); }); diff --git a/ghost/core/test/e2e-api/members/__snapshots__/well-known.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/well-known.test.js.snap index 7de35fed5e2..3f2086e953d 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/well-known.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/well-known.test.js.snap @@ -18,7 +18,7 @@ Object { "access-control-allow-credentials": "true", "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "public, max-age=86400", - "content-length": "265", + "content-length": "436", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "link": "; rel=\\"llms-txt\\", ; rel=\\"llms-full-txt\\"", diff --git a/ghost/core/test/e2e-frontend/private-comments.test.js b/ghost/core/test/e2e-frontend/private-comments.test.js new file mode 100644 index 00000000000..425dd575334 --- /dev/null +++ b/ghost/core/test/e2e-frontend/private-comments.test.js @@ -0,0 +1,79 @@ +const {agentProvider, cacheRules, fixtureManager} = require('../utils/e2e-framework'); +const models = require('../../core/server/models'); +const settingsCache = require('../../core/shared/settings-cache'); + +describe('Private site comments', function () { + const accessCode = 'private-comments-test'; + let adminAgent; + let frontendAgent; + let ghostServer; + let comment; + let originalPrivateSetting; + let originalPasswordSetting; + let settingsChanged = false; + + beforeAll(async function () { + ({adminAgent, frontendAgent, ghostServer} = await agentProvider.getAgentsWithFrontend()); + + await fixtureManager.init('posts', 'members'); + await adminAgent.loginAsOwner(); + + originalPrivateSetting = settingsCache.get('is_private'); + originalPasswordSetting = settingsCache.get('password'); + + const postId = fixtureManager.get('posts', 0).id; + await models.Post.edit({visibility: 'public'}, {id: postId}); + comment = await models.Comment.add({ + post_id: postId, + member_id: fixtureManager.get('members', 0).id, + html: '

This is a private comment

', + status: 'published' + }); + + await adminAgent.put('settings/') + .body({ + settings: [ + {key: 'password', value: accessCode}, + {key: 'is_private', value: true} + ] + }) + .expectStatus(200); + settingsChanged = true; + }); + + afterAll(async function () { + try { + if (settingsChanged) { + await adminAgent.put('settings/') + .body({ + settings: [ + {key: 'password', value: originalPasswordSetting}, + {key: 'is_private', value: originalPrivateSetting} + ] + }) + .expectStatus(200); + } + } finally { + await ghostServer?.stop(); + } + }); + + it('shares private-site access with the comments API', async function () { + await frontendAgent + .get(`/members/api/comments/${comment.id}`) + .expect(403) + .expect('Cache-Control', cacheRules.private); + + await frontendAgent + .post('/private/') + .type('form') + .send({password: accessCode}) + .expect(302) + .expect('Location', '/'); + + await frontendAgent + .get(`/members/api/comments/${comment.id}`) + .expect(200) + .expect('Cache-Control', cacheRules.private); + }); +}); diff --git a/ghost/core/test/e2e-server/__snapshots__/well-known.test.js.snap b/ghost/core/test/e2e-server/__snapshots__/well-known.test.js.snap index 1902406336f..4a49e4f0d71 100644 --- a/ghost/core/test/e2e-server/__snapshots__/well-known.test.js.snap +++ b/ghost/core/test/e2e-server/__snapshots__/well-known.test.js.snap @@ -17,7 +17,7 @@ Object { exports[`.well-known GET /jwks.json should return a JWKS 2: [headers] 1`] = ` Object { "cache-control": "public, max-age=86400", - "content-length": "277", + "content-length": "448", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Encoding", diff --git a/ghost/core/test/unit/api/endpoints/utils/api-filter-utils.test.ts b/ghost/core/test/unit/api/endpoints/utils/api-filter-utils.test.ts new file mode 100644 index 00000000000..2e54fcddb11 --- /dev/null +++ b/ghost/core/test/unit/api/endpoints/utils/api-filter-utils.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { + rejectAdminApiRestrictedFieldsTransformer, + validateAdminApiBulkFilterTransformer +} from '../../../../../core/server/api/endpoints/utils/api-filter-utils'; + +describe('API filter utils', function () { + describe('rejectAdminApiRestrictedFieldsTransformer', function () { + it('removes restricted fields from read filters', function () { + assert.deepEqual(rejectAdminApiRestrictedFieldsTransformer({ + $and: [ + {'authors.password': 'hash'}, + {status: 'published'} + ] + }), { + $and: [ + {status: 'published'} + ] + }); + }); + }); + + describe('validateAdminApiBulkFilterTransformer', function () { + it('returns bulk filters without restricted fields unchanged', function () { + const filter = { + $and: [ + {status: 'draft'}, + {type: 'post'} + ] + }; + + assert.equal(validateAdminApiBulkFilterTransformer(filter), filter); + }); + + it('rejects restricted fields anywhere in a bulk filter path', function () { + assert.throws(() => validateAdminApiBulkFilterTransformer({ + $or: [ + {status: 'draft'}, + {'authors.password.hash': 'guess'} + ] + }), { + name: 'BadRequestError', + message: 'Restricted fields cannot be used in bulk operation filters.' + }); + }); + + it('matches restricted field names case-insensitively', function () { + assert.throws(() => validateAdminApiBulkFilterTransformer({ + 'authors.Password': 'guess' + }), { + name: 'BadRequestError' + }); + }); + }); +}); diff --git a/ghost/core/test/unit/frontend/apps/private-blogging/middleware.test.js b/ghost/core/test/unit/frontend/apps/private-blogging/middleware.test.js index 096211f7982..af18d07561d 100644 --- a/ghost/core/test/unit/frontend/apps/private-blogging/middleware.test.js +++ b/ghost/core/test/unit/frontend/apps/private-blogging/middleware.test.js @@ -49,6 +49,14 @@ describe('Private Blogging', function () { sinon.assert.called(next); assert.equal(res.isPrivateBlog, true); }); + + it('Sets res.isPrivateBlog true if setting is truthy', function () { + settingsStub.withArgs('is_private').returns('true'); + + privateBlogging.checkIsPrivate(req, res, next); + sinon.assert.called(next); + assert.equal(res.isPrivateBlog, true); + }); }); // The remainder of these tests set res.isPrivateBlog true or false directly diff --git a/ghost/core/test/unit/server/services/email-service/events/email-bounced-event.test.js b/ghost/core/test/unit/server/services/email-service/events/email-bounced-event.test.js index 201bb56912d..f3914f34863 100644 --- a/ghost/core/test/unit/server/services/email-service/events/email-bounced-event.test.js +++ b/ghost/core/test/unit/server/services/email-service/events/email-bounced-event.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const ObjectID = require('bson-objectid').default; -const EmailBouncedEvent = require('../../../../../../core/server/services/email-service/events/email-bounced-event'); +const {EmailBouncedEvent} = require('../../../../../../core/server/services/email-service/events/email-bounced-event'); describe('EmailBouncedEvent', function () { it('exports a static create method to create instances', function () { diff --git a/ghost/core/test/unit/server/services/email-service/events/email-delivered-event.test.js b/ghost/core/test/unit/server/services/email-service/events/email-delivered-event.test.js index f0ac4a59052..f361599e471 100644 --- a/ghost/core/test/unit/server/services/email-service/events/email-delivered-event.test.js +++ b/ghost/core/test/unit/server/services/email-service/events/email-delivered-event.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const ObjectID = require('bson-objectid').default; -const EmailDeliveredEvent = require('../../../../../../core/server/services/email-service/events/email-delivered-event'); +const {EmailDeliveredEvent} = require('../../../../../../core/server/services/email-service/events/email-delivered-event'); describe('EmailDeliveredEvent', function () { it('exports a static create method to create instances', function () { diff --git a/ghost/core/test/unit/server/services/email-service/events/email-opened-event.test.js b/ghost/core/test/unit/server/services/email-service/events/email-opened-event.test.js index a2ebec9888f..1d611ba80c0 100644 --- a/ghost/core/test/unit/server/services/email-service/events/email-opened-event.test.js +++ b/ghost/core/test/unit/server/services/email-service/events/email-opened-event.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const ObjectID = require('bson-objectid').default; -const EmailOpenedEvent = require('../../../../../../core/server/services/email-service/events/email-opened-event'); +const {EmailOpenedEvent} = require('../../../../../../core/server/services/email-service/events/email-opened-event'); describe('EmailOpenedEvent', function () { it('exports a static create method to create instances', function () { diff --git a/ghost/core/test/unit/server/services/email-service/events/email-temporary-bounced-event.test.js b/ghost/core/test/unit/server/services/email-service/events/email-temporary-bounced-event.test.js index ad22c0879cc..7759f4f8b88 100644 --- a/ghost/core/test/unit/server/services/email-service/events/email-temporary-bounced-event.test.js +++ b/ghost/core/test/unit/server/services/email-service/events/email-temporary-bounced-event.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const ObjectID = require('bson-objectid').default; -const EmailTemporaryBouncedEvent = require('../../../../../../core/server/services/email-service/events/email-temporary-bounced-event'); +const {EmailTemporaryBouncedEvent} = require('../../../../../../core/server/services/email-service/events/email-temporary-bounced-event'); describe('EmailTemporaryBouncedEvent', function () { it('exports a static create method to create instances', function () { diff --git a/ghost/core/test/unit/server/services/email-service/events/email-unsubscribed-event.test.js b/ghost/core/test/unit/server/services/email-service/events/email-unsubscribed-event.test.js index af09a2887f2..177040dd3a2 100644 --- a/ghost/core/test/unit/server/services/email-service/events/email-unsubscribed-event.test.js +++ b/ghost/core/test/unit/server/services/email-service/events/email-unsubscribed-event.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const ObjectID = require('bson-objectid').default; -const EmailUnsubscribedEvent = require('../../../../../../core/server/services/email-service/events/email-unsubscribed-event'); +const {EmailUnsubscribedEvent} = require('../../../../../../core/server/services/email-service/events/email-unsubscribed-event'); describe('EmailUnsubscribedEvent', function () { it('exports a static create method to create instances', function () { diff --git a/ghost/core/test/unit/server/services/email-service/events/spam-complaint-event.test.js b/ghost/core/test/unit/server/services/email-service/events/spam-complaint-event.test.js index af8b5c69b63..a259b0d5485 100644 --- a/ghost/core/test/unit/server/services/email-service/events/spam-complaint-event.test.js +++ b/ghost/core/test/unit/server/services/email-service/events/spam-complaint-event.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const ObjectID = require('bson-objectid').default; -const SpamComplaintEvent = require('../../../../../../core/server/services/email-service/events/spam-complaint-event'); +const {SpamComplaintEvent} = require('../../../../../../core/server/services/email-service/events/spam-complaint-event'); describe('SpamComplaintEvent', function () { it('exports a static create method to create instances', function () { diff --git a/ghost/core/test/unit/server/services/email-service/newsletter-email-event-storage.test.js b/ghost/core/test/unit/server/services/email-service/newsletter-email-event-storage.test.js index f4cb8e34b0e..17ddb571977 100644 --- a/ghost/core/test/unit/server/services/email-service/newsletter-email-event-storage.test.js +++ b/ghost/core/test/unit/server/services/email-service/newsletter-email-event-storage.test.js @@ -6,12 +6,12 @@ const logging = require('@tryghost/logging'); const {createDb, createPrometheusClient} = require('./utils'); const config = require('../../../../../core/shared/config'); -const EmailDeliveredEvent = require('../../../../../core/server/services/email-service/events/email-delivered-event'); -const EmailOpenedEvent = require('../../../../../core/server/services/email-service/events/email-opened-event'); -const EmailBouncedEvent = require('../../../../../core/server/services/email-service/events/email-bounced-event'); -const EmailTemporaryBouncedEvent = require('../../../../../core/server/services/email-service/events/email-temporary-bounced-event'); -const EmailUnsubscribedEvent = require('../../../../../core/server/services/email-service/events/email-unsubscribed-event'); -const SpamComplaintEvent = require('../../../../../core/server/services/email-service/events/spam-complaint-event'); +const {EmailDeliveredEvent} = require('../../../../../core/server/services/email-service/events/email-delivered-event'); +const {EmailOpenedEvent} = require('../../../../../core/server/services/email-service/events/email-opened-event'); +const {EmailBouncedEvent} = require('../../../../../core/server/services/email-service/events/email-bounced-event'); +const {EmailTemporaryBouncedEvent} = require('../../../../../core/server/services/email-service/events/email-temporary-bounced-event'); +const {EmailUnsubscribedEvent} = require('../../../../../core/server/services/email-service/events/email-unsubscribed-event'); +const {SpamComplaintEvent} = require('../../../../../core/server/services/email-service/events/spam-complaint-event'); const createEventStorage = (dependencies = {}) => new NewsletterEmailEventStorage({config, ...dependencies}); diff --git a/ghost/core/test/unit/server/services/members-events/last-seen-at-updater.test.js b/ghost/core/test/unit/server/services/members-events/last-seen-at-updater.test.js index 202993eb72e..7d8e70a87d8 100644 --- a/ghost/core/test/unit/server/services/members-events/last-seen-at-updater.test.js +++ b/ghost/core/test/unit/server/services/members-events/last-seen-at-updater.test.js @@ -4,7 +4,7 @@ const LastSeenAtUpdater = require('../../../../../core/server/services/members-e const DomainEvents = require('@tryghost/domain-events'); const {MemberPageViewEvent, MemberCommentEvent, MemberSubscribeEvent, MemberLinkClickEvent} = require('../../../../../core/shared/events'); const moment = require('moment'); -const EmailOpenedEvent = require('../../../../../core/server/services/email-service/events/email-opened-event'); +const {EmailOpenedEvent} = require('../../../../../core/server/services/email-service/events/email-opened-event'); const EventEmitter = require('events'); const logging = require('@tryghost/logging'); diff --git a/ghost/core/test/unit/server/services/permissions/can-this.test.js b/ghost/core/test/unit/server/services/permissions/can-this.test.js index d2fcbda3023..4c4c810d56f 100644 --- a/ghost/core/test/unit/server/services/permissions/can-this.test.js +++ b/ghost/core/test/unit/server/services/permissions/can-this.test.js @@ -559,6 +559,39 @@ describe('Permissions', function () { sinon.assert.calledOnce(apiKeyProviderStub); assert.equal(res, undefined); }); + + it('Expected: Author user + API key cannot update the visibility of their own post', async function () { + const userProviderStub = sinon.stub(providers, 'user').callsFake(function () { + return Promise.resolve({ + permissions: models.Permissions.forge(testUtils.DataGenerator.Content.permissions).models, + roles: [testUtils.DataGenerator.Content.roles[2]] // Author role + }); + }); + + const apiKeyProviderStub = sinon.stub(providers, 'apiKey').callsFake(function () { + return Promise.resolve({ + permissions: models.Permissions.forge(testUtils.DataGenerator.Content.permissions).models, + roles: [testUtils.DataGenerator.Content.roles[5]] // Admin Integration + }) + }); + + await assert.rejects(permissions + .canThis({ + user: testUtils.DataGenerator.Content.users[0].id, + api_key: {id: 123, type: 'admin'} + }) + .edit + .post({id: 1}, {visibility: 'members'}), + function (err) { + assert.equal(err.errorType, 'NoPermissionError'); + return true; + } + ); + + sinon.assert.calledOnce(userProviderStub); + sinon.assert.calledOnce(apiKeyProviderStub); + sinon.assert.calledOnce(findPostSpy); + }); }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa246113d70..63c82823765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2520,9 +2520,6 @@ importers: juice: specifier: 9.1.0 version: 9.1.0(encoding@0.1.13) - keypair: - specifier: 1.0.4 - version: 1.0.4 knex: specifier: 2.4.2 version: 2.4.2(better-sqlite3@12.11.1)(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2) @@ -16341,9 +16338,6 @@ packages: resolution: {gitHosted: true, integrity: sha512-/WVovQslVEqPGNoD97TbqNHuCDPYu2v4/ggrZj0a+9PVPw3Rud4Ut2K7fOi0kMqzoJINkgP68e9m09Al/wFZ8g==, tarball: https://codeload.github.com/madrobby/keymaster/tar.gz/f8f43ddafad663b505dc0908e72853bcf8daea49} version: 1.6.3 - keypair@1.0.4: - resolution: {integrity: sha512-zwhgOhhniaL7oxMgUMKKw5219PWWABMO+dgMnzJOQ2/5L3XJtTJGhW2PEXlxXj9zaccdReZJZ83+4NPhVfNVDg==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -29980,9 +29974,9 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) '@vitest/expect@3.2.4': dependencies: @@ -30063,7 +30057,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -39328,8 +39322,6 @@ snapshots: keymaster@https://codeload.github.com/madrobby/keymaster/tar.gz/f8f43ddafad663b505dc0908e72853bcf8daea49: {} - keypair@1.0.4: {} - keyv@4.5.4: dependencies: json-buffer: 3.0.1