diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb2eb2a4..9d64bc167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.19.0] - Not released +### Fixed +- A realtime bug that caused data leakage via the 'post:update' and + 'post:destroy' events. + + The sample scenario was as follows: + - UserA subscribed to UserB, UserB subscribed to UserC, UserC is private; + - UserB likes post of UserC; + - UserC updates that post. + + In this case, the UserA could receive the 'post:update' event with the full + content of updated post (which is normally not available to him). ## [2.18.0] - 2024-01-19 ### Changed diff --git a/app/models/post.js b/app/models/post.js index 92b73ddd0..9d73c87c5 100644 --- a/app/models/post.js +++ b/app/models/post.js @@ -314,11 +314,13 @@ export function addModel(dbAdapter) { const uuids = await dbAdapter.getPostLongIds(getUpdatedShortIds(this.body)); uuids.push(...getUpdatedUUIDs(this.body)); - const [realtimeRooms, comments, groups, notifyBacklinked] = await Promise.all([ + const [realtimeRooms, comments, groups, notifyBacklinked, onlyForUsers] = await Promise.all([ getRoomsOfPost(this), this.getComments(), this.getGroupsPostedTo(), notifyBacklinkedLater(this, pubSub, uuids), + // We need to save the post viewers before destroying the post + this.usersCanSee(), ]); // remove all comments @@ -328,7 +330,7 @@ export function addModel(dbAdapter) { await dbAdapter.deletePost(this.id); await Promise.all([ - pubSub.destroyPost(this.id, realtimeRooms), + pubSub.destroyPost(this.id, realtimeRooms, onlyForUsers), destroyedBy ? EventService.onPostDestroyed(this, destroyedBy, { groups }) : null, notifyBacklinked(), ]); @@ -873,7 +875,7 @@ export function addModel(dbAdapter) { * Returns ids of all users who can see this post. * Ids are returned as (possible open) list defined in support/open-lists.js * - * @returns {Promise} + * @returns {Promise>} */ async usersCanSee() { return await dbAdapter.getUsersWhoCanSeePost({ diff --git a/app/pubsub-listener.js b/app/pubsub-listener.js index 295cfddd7..d2536d4df 100644 --- a/app/pubsub-listener.js +++ b/app/pubsub-listener.js @@ -263,6 +263,7 @@ export default class PubsubListener { // Only for the POST_UPDATED events: the new and removed post viewers IDs newUsers = List.empty(), removedUsers = List.empty(), + keptUsers = List.empty(), } = {}, ) { if (rooms.length === 0) { @@ -323,6 +324,8 @@ export default class PubsubListener { userIds = List.difference(userIds, removedUserIds).items; } + + emitter = this._onlyUsersEmitter(keptUsers, emitter); } else { const allPostReaders = await post.usersCanSee(); userIds = List.intersection(allPostReaders, userIds).items; @@ -393,10 +396,15 @@ export default class PubsubListener { }; // Message-handlers follow - onPostDestroy = async ({ postId, rooms }) => { + onPostDestroy = async ({ + postId, + rooms, + // The JSON of List.everything() + onlyForUsers = { items: [], inclusive: false }, + }) => { const json = { meta: { postId } }; const type = eventNames.POST_DESTROYED; - await this.broadcastMessage(rooms, type, json); + await this.broadcastMessage(rooms, type, json, { onlyForUsers: List.from(onlyForUsers) }); }; onPostNew = async ({ postId }) => { @@ -430,13 +438,18 @@ export default class PubsubListener { emitter: this._postEventEmitter, }; + // It is possible that after the update of the posts + // destinations it will become invisible or visible for the some users. + // 'broadcastMessage' will send 'post:destroy' or 'post:new' to such users. + const currentUserIds = await post.usersCanSee(); + if (usersBeforeIds) { - // It is possible that after the update of the posts - // destinations it will become invisible or visible for the some users. - // 'broadcastMessage' will send 'post:destroy' or 'post:new' to such users. - const currentUserIds = await post.usersCanSee(); broadcastOptions.newUsers = List.difference(currentUserIds, usersBeforeIds); broadcastOptions.removedUsers = List.difference(usersBeforeIds, currentUserIds); + // These users should receive the 'post:update' event. + broadcastOptions.keptUsers = List.intersection(usersBeforeIds, currentUserIds); + } else { + broadcastOptions.keptUsers = currentUserIds; } await this.broadcastMessage(rooms, eventNames.POST_UPDATED, { postId }, broadcastOptions); diff --git a/app/pubsub.ts b/app/pubsub.ts index 46bdad506..1b16e9bf9 100644 --- a/app/pubsub.ts +++ b/app/pubsub.ts @@ -54,8 +54,8 @@ export default class pubSub { await this.publisher.postCreated(payload); } - async destroyPost(postId: UUID, rooms: string[]) { - const payload = JSON.stringify({ postId, rooms }); + async destroyPost(postId: UUID, rooms: string[], onlyForUsers: List) { + const payload = JSON.stringify({ postId, rooms, onlyForUsers }); await this.publisher.postDestroyed(payload); } diff --git a/test/functional/realtime-posts-privacy.js b/test/functional/realtime-posts-privacy.js new file mode 100644 index 000000000..22c9bdda5 --- /dev/null +++ b/test/functional/realtime-posts-privacy.js @@ -0,0 +1,156 @@ +/* eslint-env node, mocha */ +import expect from 'unexpected'; + +import { getSingleton } from '../../app/app'; +import { PubSubAdapter, eventNames as ev } from '../../app/support/PubSubAdapter'; +import { PubSub } from '../../app/models'; +import { connect as pgConnect } from '../../app/setup/postgres'; +import redisDb from '../../app/setup/database'; +import cleanDB from '../dbCleaner'; + +import Session from './realtime-session'; +import { + createTestUsers, + createAndReturnPost, + like, + performJSONRequest, + authHeaders, + createCommentAsync, + likeComment, +} from './functional_test_helper'; + +describe('Realtime events from inaccessible posts', () => { + let port; + + before(async () => { + const app = await getSingleton(); + port = process.env.PEPYATKA_SERVER_PORT || app.context.config.port; + const pubsubAdapter = new PubSubAdapter(redisDb); + PubSub.setPublisher(pubsubAdapter); + }); + beforeEach(() => cleanDB(pgConnect())); + + let luna, mars, venus, lunaSession, marsSession; + let post; + + // Luna subscribed to Mars, Mars subscribed to Venus, Venus is private + beforeEach(async () => { + [luna, mars, venus] = await createTestUsers(['luna', 'mars', 'venus']); + + [lunaSession, marsSession] = await Promise.all([ + Session.create(port, 'Luna session'), + Session.create(port, 'Mars session'), + ]); + + // Luna subscribed to Mars, Mars subscribed to Venus + await Promise.all([luna.user.subscribeTo(mars.user), mars.user.subscribeTo(venus.user)]); + // Venus goes private + await venus.user.update({ isPrivate: '1', isProtected: '1' }); + + await Promise.all([ + lunaSession.sendAsync('auth', { authToken: luna.authToken }), + marsSession.sendAsync('auth', { authToken: mars.authToken }), + ]); + + // Luna and Mars are listening to their home feeds + const [lunaHomeFeedId, marsHomeFeedId] = await Promise.all([ + luna.user.getRiverOfNewsTimelineId(), + mars.user.getRiverOfNewsTimelineId(), + ]); + await Promise.all([ + lunaSession.sendAsync('subscribe', { timeline: [lunaHomeFeedId] }), + marsSession.sendAsync('subscribe', { timeline: [marsHomeFeedId] }), + ]); + + // Venus creates post + post = await createAndReturnPost(venus, 'Venus post'); + }); + + describe('Mars likes Venus post', () => { + it(`should deliver ${ev.LIKE_ADDED} to Mars`, async () => { + const test = marsSession.receiveWhile(ev.LIKE_ADDED, () => like(post.id, mars.authToken)); + await expect(test, 'to be fulfilled'); + }); + + it(`should NOT deliver ${ev.LIKE_ADDED} to Luna`, async () => { + const test = lunaSession.notReceiveWhile(ev.LIKE_ADDED, () => like(post.id, mars.authToken)); + await expect(test, 'to be fulfilled'); + }); + }); + + describe('Venus updates post liked by Mars', () => { + beforeEach(() => like(post.id, mars.authToken)); + + const updatePost = () => + performJSONRequest( + 'PUT', + `/v2/posts/${post.id}`, + { post: { body: 'Updated Venus post' } }, + authHeaders(venus), + ); + + it(`should deliver ${ev.POST_UPDATED} to Mars`, async () => { + const test = marsSession.receiveWhile(ev.POST_UPDATED, updatePost); + await expect(test, 'to be fulfilled'); + }); + + it(`should NOT deliver ${ev.POST_UPDATED} to Luna`, async () => { + const test = lunaSession.notReceiveWhile(ev.POST_UPDATED, updatePost); + await expect(test, 'to be fulfilled'); + }); + }); + + describe('Venus removes post liked by Mars', () => { + beforeEach(() => like(post.id, mars.authToken)); + + const deletePost = () => + performJSONRequest('DELETE', `/v2/posts/${post.id}`, null, authHeaders(venus)); + + it(`should deliver ${ev.POST_DESTROYED} to Mars`, async () => { + const test = marsSession.receiveWhile(ev.POST_DESTROYED, deletePost); + await expect(test, 'to be fulfilled'); + }); + + it(`should NOT deliver ${ev.POST_DESTROYED} to Luna`, async () => { + const test = lunaSession.notReceiveWhile(ev.POST_DESTROYED, deletePost); + await expect(test, 'to be fulfilled'); + }); + }); + + describe('Venus commented post liked by Mars', () => { + beforeEach(() => like(post.id, mars.authToken)); + + const commentPost = () => createCommentAsync(venus, post.id, 'Hello'); + + it(`should deliver ${ev.COMMENT_CREATED} to Mars`, async () => { + const test = marsSession.receiveWhile(ev.COMMENT_CREATED, commentPost); + await expect(test, 'to be fulfilled'); + }); + + it(`should NOT deliver ${ev.COMMENT_CREATED} to Luna`, async () => { + const test = lunaSession.notReceiveWhile(ev.COMMENT_CREATED, commentPost); + await expect(test, 'to be fulfilled'); + }); + }); + + describe('Venus liked comment of post commented by Mars', () => { + let comment; + beforeEach(async () => { + comment = await createCommentAsync(mars, post.id, 'Hello') + .then((r) => r.json()) + .then((r) => r.comments); + }); + + const cLike = () => likeComment(comment.id, venus); + + it(`should deliver ${ev.COMMENT_LIKE_ADDED} to Mars`, async () => { + const test = marsSession.receiveWhile(ev.COMMENT_LIKE_ADDED, cLike); + await expect(test, 'to be fulfilled'); + }); + + it(`should NOT deliver ${ev.COMMENT_LIKE_ADDED} to Luna`, async () => { + const test = lunaSession.notReceiveWhile(ev.COMMENT_LIKE_ADDED, cLike); + await expect(test, 'to be fulfilled'); + }); + }); +});