Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix a realtime privacy leakage #661

Merged
merged 1 commit into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});
});
});
Loading