Skip to content

Commit

Permalink
Merge pull request #661 from FreeFeed/fix-realtime-privacy
Browse files Browse the repository at this point in the history
Fix a realtime privacy leakage
  • Loading branch information
davidmz authored Jan 26, 2024
2 parents 5d95a94 + c771e43 commit edafdb2
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 11 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions app/models/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
]);
Expand Down Expand Up @@ -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<List>}
* @returns {Promise<List<import('../support/types').UUID>>}
*/
async usersCanSee() {
return await dbAdapter.getUsersWhoCanSeePost({
Expand Down
25 changes: 19 additions & 6 deletions app/pubsub-listener.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions app/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>) {
const payload = JSON.stringify({ postId, rooms, onlyForUsers });
await this.publisher.postDestroyed(payload);
}

Expand Down
156 changes: 156 additions & 0 deletions test/functional/realtime-posts-privacy.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});

0 comments on commit edafdb2

Please sign in to comment.