Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Prevent local user profiles leaking when a "per-room profile" is changed #10695

Closed
wants to merge 48 commits into from
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2b42abb
Fix passing `None` to DB helpers
Aug 25, 2021
ef0a9c5
Misc refactors
Aug 25, 2021
038a9c5
changelog stub
Aug 25, 2021
9366436
Docstrings, method reorder and rename
Aug 31, 2021
00f5809
Always load local users into user_directory
Aug 31, 2021
1819999
Remove unnecessary `set()` construction
Aug 31, 2021
bf97497
Only update remote users dir. entries from events
Aug 31, 2021
b348f0b
Refactor to remove a level of indentation
Aug 31, 2021
13d6b6e
test_profile: register a user, not just a profile
Aug 31, 2021
0d0f033
Use test-level "register_user", not the store
Aug 31, 2021
cca4d10
Missed an await GRR
Aug 31, 2021
137ecc8
Register user in RoomJoinRatelimitTestCase
Sep 1, 2021
dc25098
Need the admin servlet to register_user
Sep 1, 2021
90dcd65
Fix test: handler assumes lowercase username
Sep 1, 2021
d99eedb
Grrrrrr lint
Sep 1, 2021
ee72d78
Rename to clarify local versus remote name changes
Sep 2, 2021
61d3e88
Don't leak local users' per-room names on join
Sep 2, 2021
d643d14
Update synapse/handlers/user_directory.py
Sep 2, 2021
e77bbb2
Changelog
Sep 2, 2021
e0b5bde
Method rename again
Sep 2, 2021
3e547cb
Pull out the loop from _handle_deltas
Sep 2, 2021
6f25133
Exclude support users early
Sep 2, 2021
0933f33
Exclude events from app_services users early
Sep 2, 2021
b5b87ae
Handle `joined is MatchChange.now_false` in 1 spot
Sep 2, 2021
c9a099a
Test case for making a room public
Sep 2, 2021
4bde6b1
Remove redundant comment
Sep 2, 2021
8974d33
Only remove remote users from dir
Sep 2, 2021
9272ed9
Split apart _handle_add_user
Sep 2, 2021
a1bafa3
Lint
Sep 2, 2021
a75a3a1
Linttttttt
Sep 2, 2021
ea3037b
Remove unused arg from rem. profile change handler
Sep 3, 2021
f560472
Inline remote user joining room
Sep 3, 2021
359de94
Comment where we still have work TODO
Sep 3, 2021
474c212
Typing in storage module for user_directory
Sep 3, 2021
38bbde8
Comments about leakages
Sep 3, 2021
a3c023d
db user dir: pull out processing a room to method
Sep 3, 2021
90794cb
Exit early if process_single_room if not in room
Sep 3, 2021
e90524c
Describe data model in docs
Sep 3, 2021
bc9780f
Additional comments on the bg process
Sep 3, 2021
bbacf47
Linttttttt
Sep 3, 2021
6cca7c6
Update changelog
Sep 3, 2021
d50b492
Rationale for synapse test, not complement
Sep 3, 2021
0a217b9
Oliver's suggestions from code review.
Sep 3, 2021
9c4b7ef
Fixup docs as per review
Sep 3, 2021
8662e5f
Fix coment and extra check, as per review
Sep 3, 2021
8269250
Improve docstring for _handle_remove_user
Sep 3, 2021
401c1e8
Fix reactivated users not being added to directory (#10762)
Sep 8, 2021
2b0d714
Revert "Fix reactivated users not being added to directory (#10762)"
Sep 8, 2021
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
1 change: 1 addition & 0 deletions changelog.d/10695.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TODO write a proper changelog dmr.
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
11 changes: 4 additions & 7 deletions synapse/handlers/deactivate_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ async def deactivate_account(
await self.store.add_user_pending_deactivation(user_id)

# delete from user directory
await self.user_directory_handler.handle_user_deactivated(user_id)
await self.user_directory_handler.handle_local_user_deactivated(user_id)

# Mark the user as erased, if they asked for that
if erase_data:
Expand Down Expand Up @@ -248,7 +248,7 @@ async def activate_account(self, user_id: str) -> None:
This marks the user as active and not erased in the database, but does
not attempt to rejoin rooms, re-add threepids, etc.

If enabled, the user will be re-added to the user directory.
The user will be re-added to the user directory.

The user will also need a password hash set to actually login.

Expand All @@ -257,11 +257,8 @@ async def activate_account(self, user_id: str) -> None:
"""
# Add the user to the directory, if necessary.
user = UserID.from_string(user_id)
if self.hs.config.user_directory_search_all_users:
profile = await self.store.get_profileinfo(user.localpart)
await self.user_directory_handler.handle_local_profile_change(
user_id, profile
)
profile = await self.store.get_profileinfo(user.localpart)
await self.user_directory_handler.handle_local_profile_change(user_id, profile)

# Ensure the user is not marked as erased.
await self.store.mark_user_not_erased(user_id)
Expand Down
18 changes: 8 additions & 10 deletions synapse/handlers/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,10 @@ async def set_displayname(
target_user.localpart, displayname_to_set
)

if self.hs.config.user_directory_search_all_users:
profile = await self.store.get_profileinfo(target_user.localpart)
await self.user_directory_handler.handle_local_profile_change(
target_user.to_string(), profile
)
profile = await self.store.get_profileinfo(target_user.localpart)
await self.user_directory_handler.handle_local_profile_change(
target_user.to_string(), profile
)

await self._update_join_states(requester, target_user)

Expand Down Expand Up @@ -300,11 +299,10 @@ async def set_avatar_url(
target_user.localpart, avatar_url_to_set
)

if self.hs.config.user_directory_search_all_users:
profile = await self.store.get_profileinfo(target_user.localpart)
await self.user_directory_handler.handle_local_profile_change(
target_user.to_string(), profile
)
profile = await self.store.get_profileinfo(target_user.localpart)
await self.user_directory_handler.handle_local_profile_change(
target_user.to_string(), profile
)

await self._update_join_states(requester, target_user)

Expand Down
9 changes: 4 additions & 5 deletions synapse/handlers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,10 @@ async def register_user(
shadow_banned=shadow_banned,
)

if self.hs.config.user_directory_search_all_users:
profile = await self.store.get_profileinfo(localpart)
await self.user_directory_handler.handle_local_profile_change(
user_id, profile
)
profile = await self.store.get_profileinfo(localpart)
await self.user_directory_handler.handle_local_profile_change(
user_id, profile
)

else:
# autogen a sequential user ID
Expand Down
23 changes: 12 additions & 11 deletions synapse/handlers/state_deltas.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.

import logging
from enum import Enum, auto
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
Expand All @@ -21,6 +22,12 @@
logger = logging.getLogger(__name__)


class MatchChange(Enum):
no_change = auto()
now_true = auto()
now_false = auto()


class StateDeltasHandler:
def __init__(self, hs: "HomeServer"):
self.store = hs.get_datastore()
Expand All @@ -31,18 +38,12 @@ async def _get_key_change(
event_id: Optional[str],
key_name: str,
public_value: str,
) -> Optional[bool]:
) -> MatchChange:
"""Given two events check if the `key_name` field in content changed
from not matching `public_value` to doing so.

For example, check if `history_visibility` (`key_name`) changed from
`shared` to `world_readable` (`public_value`).

Returns:
None if the field in the events either both match `public_value`
or if neither do, i.e. there has been no change.
True if it didn't match `public_value` but now does
False if it did match `public_value` but now doesn't
"""
prev_event = None
event = None
Expand All @@ -54,7 +55,7 @@ async def _get_key_change(

if not event and not prev_event:
logger.debug("Neither event exists: %r %r", prev_event_id, event_id)
return None
return MatchChange.no_change

prev_value = None
value = None
Expand All @@ -68,8 +69,8 @@ async def _get_key_change(
logger.debug("prev_value: %r -> value: %r", prev_value, value)

if value == public_value and prev_value != public_value:
return True
return MatchChange.now_true
elif value != public_value and prev_value == public_value:
return False
return MatchChange.now_false
else:
return None
return MatchChange.no_change
114 changes: 64 additions & 50 deletions synapse/handlers/user_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import synapse.metrics
from synapse.api.constants import EventTypes, HistoryVisibility, JoinRules, Membership
from synapse.handlers.state_deltas import StateDeltasHandler
from synapse.handlers.state_deltas import MatchChange, StateDeltasHandler
from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.storage.roommember import ProfileInfo
from synapse.types import JsonDict
Expand All @@ -30,14 +30,26 @@


class UserDirectoryHandler(StateDeltasHandler):
"""Handles querying of and keeping updated the user_directory.
"""Handles queries and updates for the user_directory.

N.B.: ASSUMES IT IS THE ONLY THING THAT MODIFIES THE USER DIRECTORY

The user directory is filled with users who this server can see are joined to a
world_readable or publicly joinable room. We keep a database table up to date
by streaming changes of the current state and recalculating whether users should
be in the directory or not when necessary.
When a local user searches the user_directory, we report two kinds of users:

- users this server can see are joined to a world_readable or publicly
joinable room, and
- users belonging to a private room that contains a local user.
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved

The two cases are tracked separately in the `users_in_public_rooms` and
`users_who_share_private_rooms` tables. Both kinds of users have their
username and avatar tracked in a `user_directory` table.

This handler has three responsibilities:
1. Forwarding requests to `/user_directory/search` to the UserDirectoryStore.
2. Providing hooks for the application to call when local users are added,
removed, or have their profile changed.
3. Listening for room state changes that indicate remote users have
joined or left a room, or that their profile has changed.
"""

def __init__(self, hs: "HomeServer"):
Expand Down Expand Up @@ -94,23 +106,6 @@ async def search_users(

return results

def notify_new_event(self) -> None:
"""Called when there may be more deltas to process"""
if not self.update_user_directory:
return

if self._is_processing:
return

async def process():
try:
await self._unsafe_process()
finally:
self._is_processing = False

self._is_processing = True
run_as_background_process("user_directory.notify_new_event", process)

async def handle_local_profile_change(
self, user_id: str, profile: ProfileInfo
) -> None:
Expand All @@ -130,12 +125,29 @@ async def handle_local_profile_change(
user_id, profile.display_name, profile.avatar_url
)

async def handle_user_deactivated(self, user_id: str) -> None:
async def handle_local_user_deactivated(self, user_id: str) -> None:
"""Called when a user ID is deactivated"""
# FIXME(#3714): We should probably do this in the same worker as all
# the other changes.
await self.store.remove_from_user_dir(user_id)

def notify_new_event(self) -> None:
"""Called when there may be more deltas to process"""
if not self.update_user_directory:
return

if self._is_processing:
return

async def process():
try:
await self._unsafe_process()
finally:
self._is_processing = False

self._is_processing = True
run_as_background_process("user_directory.notify_new_event", process)

async def _unsafe_process(self) -> None:
# If self.pos is None then means we haven't fetched it from DB
if self.pos is None:
Expand Down Expand Up @@ -189,14 +201,14 @@ async def _handle_deltas(self, deltas: List[Dict[str, Any]]) -> None:
room_id, prev_event_id, event_id, typ
)
elif typ == EventTypes.Member:
change = await self._get_key_change(
joined = await self._get_key_change(
prev_event_id,
event_id,
key_name="membership",
public_value=Membership.JOIN,
)

if change is False:
if joined is MatchChange.now_false:
# Need to check if the server left the room entirely, if so
# we might need to remove all the users in that room
is_in_room = await self.store.is_host_joined(
Expand All @@ -217,30 +229,32 @@ async def _handle_deltas(self, deltas: List[Dict[str, Any]]) -> None:
else:
logger.debug("Server is still in room: %r", room_id)

is_support = await self.store.is_support_user(state_key)
if not is_support:
if change is None:
# Handle any profile changes
is_support_user = await self.store.is_support_user(state_key)
if is_support_user:
continue
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved

if joined is MatchChange.no_change:
# Handle any profile changes for remote users
if not self.is_mine_id(state_key):
await self._handle_profile_change(
state_key, room_id, prev_event_id, event_id
)
continue

if change: # The user joined
event = await self.store.get_event(event_id, allow_none=True)
# It isn't expected for this event to not exist, but we
# don't want the entire background process to break.
if event is None:
continue
elif joined is MatchChange.now_true: # The user joined
event = await self.store.get_event(event_id, allow_none=True)
# It isn't expected for this event to not exist, but we
# don't want the entire background process to break.
if event is None:
continue

profile = ProfileInfo(
avatar_url=event.content.get("avatar_url"),
display_name=event.content.get("displayname"),
)
profile = ProfileInfo(
avatar_url=event.content.get("avatar_url"),
display_name=event.content.get("displayname"),
)

await self._handle_new_user(room_id, state_key, profile)
else: # The user left
await self._handle_remove_user(room_id, state_key)
await self._handle_new_user(room_id, state_key, profile)
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
else: # The user left
await self._handle_remove_user(room_id, state_key)
else:
logger.debug("Ignoring irrelevant type: %r", typ)

Expand All @@ -263,14 +277,14 @@ async def _handle_room_publicity_change(
logger.debug("Handling change for %s: %s", typ, room_id)

if typ == EventTypes.RoomHistoryVisibility:
change = await self._get_key_change(
publicity = await self._get_key_change(
prev_event_id,
event_id,
key_name="history_visibility",
public_value=HistoryVisibility.WORLD_READABLE,
)
elif typ == EventTypes.JoinRules:
change = await self._get_key_change(
publicity = await self._get_key_change(
prev_event_id,
event_id,
key_name="join_rule",
Expand All @@ -280,7 +294,7 @@ async def _handle_room_publicity_change(
raise Exception("Invalid event type")
# If change is None, no change. True => become world_readable/public,
# False => was world_readable/public
if change is None:
if publicity is MatchChange.no_change:
logger.debug("No change")
return

Expand All @@ -290,13 +304,13 @@ async def _handle_room_publicity_change(
room_id
)

logger.debug("Change: %r, is_public: %r", change, is_public)
logger.debug("Change: %r, is_public: %r", publicity, is_public)

if change and not is_public:
if publicity is MatchChange.now_true and not is_public:
# If we became world readable but room isn't currently public then
# we ignore the change
return
elif not change and is_public:
elif publicity is MatchChange.now_false and is_public:
# If we stopped being world readable but are still public,
# ignore the change
return
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Loading