Skip to content

Commit 6fda019

Browse files
Fixed private site comment read middleware (TryGhost#30045)
no ref Co-authored-by: UserExistsError <23325451+UserExistsError@users.noreply.github.com>
1 parent 2adf170 commit 6fda019

6 files changed

Lines changed: 309 additions & 67 deletions

File tree

‎ghost/core/core/frontend/apps/private-blogging/lib/middleware.js‎

Lines changed: 5 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
const fs = require('fs-extra');
2-
const session = require('cookie-session');
3-
const crypto = require('crypto');
42
const path = require('path');
53
const config = require('../../../../shared/config');
64
const urlUtils = require('../../../../shared/url-utils').default;
75
const tpl = require('@tryghost/tpl');
86
const errors = require('@tryghost/errors');
97
const settingsCache = require('../../../../shared/settings-cache');
8+
const privateSiteAccess = require('../../../../shared/private-site-access');
109
// routeKeywords.private: 'private'
1110
const privateRoute = '/private/';
1211

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

18-
function getAccessCode() {
19-
const accessCode = settingsCache.get('password');
20-
return typeof accessCode === 'string' ? accessCode : '';
21-
}
22-
23-
function hasAccessCode(accessCode) {
24-
return typeof accessCode === 'string' && accessCode.trim().length > 0;
25-
}
26-
27-
function verifySessionHash(salt, hash) {
28-
const accessCode = getAccessCode();
29-
30-
if (!salt || !hash || !hasAccessCode(accessCode)) {
31-
return false;
32-
}
33-
34-
let hasher = crypto.createHash('sha256');
35-
hasher.update(accessCode + salt, 'utf8');
36-
return hasher.digest('hex') === hash;
37-
}
38-
3917
function getRedirectUrl(query) {
4018
try {
4119
const redirect = decodeURIComponent(query.r || '/');
@@ -57,11 +35,7 @@ function getRedirectUrl(query) {
5735
}
5836

5937
function authenticatePrivateSession(req, res, next) {
60-
const hash = req.session.token || '';
61-
const salt = req.session.salt || '';
62-
const isVerified = verifySessionHash(salt, hash);
63-
64-
if (isVerified) {
38+
if (privateSiteAccess.hasAccess(req)) {
6539
return next();
6640
} else {
6741
let redirectUrl = urlUtils.urlFor({relativeUrl: privateRoute});
@@ -72,23 +46,7 @@ function authenticatePrivateSession(req, res, next) {
7246
}
7347

7448
const privateBlogging = {
75-
checkIsPrivate: function checkIsPrivate(req, res, next) {
76-
let isPrivateBlog = settingsCache.get('is_private');
77-
78-
if (!isPrivateBlog) {
79-
res.isPrivateBlog = false;
80-
return next();
81-
}
82-
83-
res.isPrivateBlog = true;
84-
85-
return session({
86-
name: 'ghost-private',
87-
maxAge: (30 * 24 * 60 * 60 * 1000), // 30 days in ms
88-
signed: false,
89-
sameSite: 'none'
90-
})(req, res, next);
91-
},
49+
checkIsPrivate: privateSiteAccess.loadSession,
9250

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

148-
const hash = req.session.token || '';
149-
const salt = req.session.salt || '';
150-
const isVerified = verifySessionHash(salt, hash);
151-
152-
if (isVerified) {
106+
if (privateSiteAccess.hasAccess(req)) {
153107
// redirect to home if user is already authenticated
154108
return res.redirect(urlUtils.urlFor('home', true));
155109
} else {
@@ -164,16 +118,9 @@ const privateBlogging = {
164118
}
165119

166120
const submittedAccessCode = req.body && req.body.password;
167-
const accessCode = getAccessCode();
168-
const hasher = crypto.createHash('sha256');
169-
const salt = Date.now().toString();
170121
const forward = getRedirectUrl(req.query);
171122

172-
if (hasAccessCode(accessCode) && hasAccessCode(submittedAccessCode) && accessCode === submittedAccessCode) {
173-
hasher.update(submittedAccessCode + salt, 'utf8');
174-
req.session.token = hasher.digest('hex');
175-
req.session.salt = salt;
176-
123+
if (privateSiteAccess.grantAccess(req, submittedAccessCode)) {
177124
return res.redirect(urlUtils.urlFor({relativeUrl: forward}));
178125
} else {
179126
res.error = {

‎ghost/core/core/server/web/comments/routes.js‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ const tpl = require('@tryghost/tpl');
88

99
const bodyParser = require('body-parser');
1010
const membersService = require('../../../server/services/members');
11+
const privateSiteAccess = require('../../../shared/private-site-access');
1112

1213
const messages = {
13-
memberCommentingDisabled: 'Your commenting ability has been disabled.'
14+
memberCommentingDisabled: 'Your commenting ability has been disabled.',
15+
privateSiteAccessRequired: 'Comment browsing is not available'
1416
};
1517

1618
/**
@@ -27,29 +29,50 @@ function checkMemberCommenting(req, res, next) {
2729
next();
2830
}
2931

32+
/**
33+
* Middleware to reject comment read requests without a valid private-site
34+
* session when the site is in private mode.
35+
*/
36+
function checkCanReadComments(req, res, next) {
37+
if (res.isPrivateBlog && !privateSiteAccess.hasAccess(req)) {
38+
return next(new errors.NoPermissionError({
39+
message: tpl(messages.privateSiteAccessRequired)
40+
}));
41+
}
42+
next();
43+
}
44+
3045
/**
3146
* @returns {import('express').Router}
3247
*/
3348
module.exports = function apiRoutes() {
3449
const router = express.Router('comment api');
3550
router.use(bodyParser.json({limit: '50mb'}));
51+
router.use(privateSiteAccess.loadSession);
3652

37-
const countsCache = shared.middleware.cacheControl(
53+
const publicCountsCache = shared.middleware.cacheControl(
3854
'public',
3955
{maxAge: config.get('caching:commentsCountAPI:maxAge')}
4056
);
41-
router.get('/counts', countsCache, http(api.commentsMembers.counts));
57+
const privateCountsCache = shared.middleware.cacheControl('private');
58+
const countsCache = (req, res, next) => {
59+
if (res.isPrivateBlog) {
60+
return privateCountsCache(req, res, next);
61+
}
62+
return publicCountsCache(req, res, next);
63+
};
64+
router.get('/counts', checkCanReadComments, countsCache, http(api.commentsMembers.counts));
4265

43-
// Authenticated Routes
66+
// Load the optional member session for member-specific comment state
4467
router.use(membersService.middleware.loadMemberSession);
4568

4669
// Enforce capped limit parameter
4770
router.use(shared.middleware.maxLimitCap);
4871

49-
router.get('/', http(api.commentsMembers.browse));
50-
router.get('/post/:post_id', http(api.commentsMembers.browse));
51-
router.get('/:id', http(api.commentsMembers.read));
52-
router.get('/:id/replies', http(api.commentsMembers.replies));
72+
router.get('/', checkCanReadComments, http(api.commentsMembers.browse));
73+
router.get('/post/:post_id', checkCanReadComments, http(api.commentsMembers.browse));
74+
router.get('/:id', checkCanReadComments, http(api.commentsMembers.read));
75+
router.get('/:id/replies', checkCanReadComments, http(api.commentsMembers.replies));
5376

5477
// Write operations require member to have commenting ability enabled
5578
router.post('/', checkMemberCommenting, http(api.commentsMembers.add));
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
const crypto = require('crypto');
2+
const session = require('cookie-session');
3+
const settingsCache = require('../settings-cache');
4+
5+
const privateSession = session({
6+
name: 'ghost-private',
7+
maxAge: 30 * 24 * 60 * 60 * 1000,
8+
signed: false,
9+
sameSite: 'none'
10+
});
11+
12+
function getAccessCode() {
13+
const accessCode = settingsCache.get('password');
14+
return typeof accessCode === 'string' ? accessCode : '';
15+
}
16+
17+
function hasAccessCode(accessCode) {
18+
return typeof accessCode === 'string' && accessCode.trim().length > 0;
19+
}
20+
21+
function hashAccessCode(accessCode, salt) {
22+
const hasher = crypto.createHash('sha256');
23+
hasher.update(accessCode + salt, 'utf8');
24+
return hasher.digest('hex');
25+
}
26+
27+
function hasAccess(req) {
28+
const accessCode = getAccessCode();
29+
const salt = req.session?.salt || '';
30+
const hash = req.session?.token || '';
31+
32+
if (!salt || !hash || !hasAccessCode(accessCode)) {
33+
return false;
34+
}
35+
36+
return hashAccessCode(accessCode, salt) === hash;
37+
}
38+
39+
function grantAccess(req, submittedAccessCode) {
40+
const accessCode = getAccessCode();
41+
42+
if (!hasAccessCode(accessCode) || !hasAccessCode(submittedAccessCode) || accessCode !== submittedAccessCode) {
43+
return false;
44+
}
45+
46+
const salt = Date.now().toString();
47+
req.session.token = hashAccessCode(submittedAccessCode, salt);
48+
req.session.salt = salt;
49+
return true;
50+
}
51+
52+
function loadSession(req, res, next) {
53+
const isPrivateBlog = settingsCache.get('is_private');
54+
55+
if (!isPrivateBlog) {
56+
res.isPrivateBlog = false;
57+
return next();
58+
}
59+
60+
res.isPrivateBlog = true;
61+
return privateSession(req, res, next);
62+
}
63+
64+
module.exports = {
65+
grantAccess,
66+
hasAccess,
67+
loadSession
68+
};

‎ghost/core/test/e2e-api/members-comments/comments.test.js‎

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
const assert = require('node:assert/strict');
2-
const {agentProvider, mockManager, fixtureManager, matchers, configUtils, dbUtils} = require('../../utils/e2e-framework');
2+
const {agentProvider, mockManager, fixtureManager, matchers, configUtils, dbUtils, cacheRules} = require('../../utils/e2e-framework');
33
const {nullable, anyEtag, anyObjectId, anyLocationFor, anyISODateTime, anyErrorId, anyUuid, anyNumber, anyBoolean, stringMatching} = matchers;
44
const models = require('../../../core/server/models');
55
const moment = require('moment-timezone');
66
const settingsCache = require('../../../core/shared/settings-cache');
77
const sinon = require('sinon');
88
const DomainEvents = require('@tryghost/domain-events');
9+
const TestAgent = require('../../utils/agents/test-agent');
10+
const membersService = require('../../../core/server/services/members');
11+
const privateSiteAccess = require('../../../core/shared/private-site-access');
912

1013
let membersAgent, membersAgent2, postId, postAuthorEmail, postTitle;
1114
let emailMockReceiver;
@@ -2539,4 +2542,118 @@ describe('Comments API', function () {
25392542
});
25402543
});
25412544
});
2545+
2546+
// Require private-site access for comment reads in private mode
2547+
describe('When site is in private mode', function () {
2548+
const privateAccessCode = 'private-comments-test';
2549+
let comment;
2550+
let originalIsPrivateSetting;
2551+
let originalPasswordSetting;
2552+
2553+
function createSiteAgent() {
2554+
return new TestAgent(membersAgent.app, {
2555+
apiURL: '',
2556+
originURL: configUtils.config.get('url')
2557+
});
2558+
}
2559+
2560+
async function loginAsMember(agent) {
2561+
const magicLink = await membersService.api.getMagicLink('member-any@example.com', 'signin');
2562+
const token = new URL(magicLink).searchParams.get('token');
2563+
await agent.get(`/members/?token=${token}`).expectStatus(302);
2564+
}
2565+
2566+
function grantPrivateSiteAccess(agent) {
2567+
const req = {session: {}};
2568+
assert.equal(privateSiteAccess.grantAccess(req, privateAccessCode), true);
2569+
2570+
const sessionCookie = Buffer.from(JSON.stringify(req.session)).toString('base64');
2571+
agent.jar.setCookies([`ghost-private=${sessionCookie}; path=/; httponly`]);
2572+
}
2573+
2574+
function commentReadPaths() {
2575+
return [
2576+
'/members/api/comments/counts',
2577+
'/members/api/comments',
2578+
`/members/api/comments/post/${postId}`,
2579+
`/members/api/comments/${comment.id}`,
2580+
`/members/api/comments/${comment.id}/replies`
2581+
];
2582+
}
2583+
2584+
async function expectCommentReadStatus(agent, status) {
2585+
for (const path of commentReadPaths()) {
2586+
await agent.get(path).expectStatus(status);
2587+
}
2588+
}
2589+
2590+
beforeAll(async function () {
2591+
await models.Post.edit({visibility: 'public'}, {id: postId});
2592+
2593+
originalIsPrivateSetting = settingsCache.get('is_private', {resolve: false});
2594+
originalPasswordSetting = settingsCache.get('password', {resolve: false});
2595+
});
2596+
2597+
beforeEach(async function () {
2598+
settingsCache.set('is_private', {...originalIsPrivateSetting, value: true});
2599+
settingsCache.set('password', {...originalPasswordSetting, value: privateAccessCode});
2600+
2601+
comment = await dbFns.addComment({
2602+
post_id: postId,
2603+
member_id: fixtureManager.get('members', 0).id
2604+
});
2605+
});
2606+
2607+
afterAll(async function () {
2608+
settingsCache.set('is_private', originalIsPrivateSetting);
2609+
settingsCache.set('password', originalPasswordSetting);
2610+
});
2611+
2612+
it('Rejects anonymous visitors without private-site access', async function () {
2613+
await expectCommentReadStatus(createSiteAgent(), 403);
2614+
});
2615+
2616+
it('Does not treat a member session as private-site access', async function () {
2617+
const agent = createSiteAgent();
2618+
await loginAsMember(agent);
2619+
await expectCommentReadStatus(agent, 403);
2620+
});
2621+
2622+
it('Allows visitors with private-site access', async function () {
2623+
const agent = createSiteAgent();
2624+
grantPrivateSiteAccess(agent);
2625+
await expectCommentReadStatus(agent, 200);
2626+
});
2627+
2628+
it('Allows members with private-site access', async function () {
2629+
const agent = createSiteAgent();
2630+
await loginAsMember(agent);
2631+
grantPrivateSiteAccess(agent);
2632+
await expectCommentReadStatus(agent, 200);
2633+
});
2634+
2635+
it('Invalidates private-site access when the access code changes', async function () {
2636+
const agent = createSiteAgent();
2637+
grantPrivateSiteAccess(agent);
2638+
2639+
settingsCache.set('password', {...originalPasswordSetting, value: 'changed-private-comments-test'});
2640+
2641+
await agent.get('/members/api/comments').expectStatus(403);
2642+
});
2643+
2644+
it('Prevents shared caching of private comment counts', async function () {
2645+
const agent = createSiteAgent();
2646+
grantPrivateSiteAccess(agent);
2647+
2648+
const response = await agent.get('/members/api/comments/counts').expectStatus(200);
2649+
assert.equal(response.headers['cache-control'], cacheRules.private);
2650+
});
2651+
2652+
it('Keeps public comment counts publicly cacheable', async function () {
2653+
settingsCache.set('is_private', {...originalIsPrivateSetting, value: false});
2654+
2655+
const response = await createSiteAgent().get('/members/api/comments/counts').expectStatus(200);
2656+
assert.match(response.headers['cache-control'], /^public, max-age=/);
2657+
});
2658+
});
25422659
});

0 commit comments

Comments
 (0)