Skip to content

Commit d713ba0

Browse files
fix(MSC3911): Handle profile avatars that are unknown gracefully (#148)
Related: famedly/product-management#3448 Be more selective about what avatar url's are allowed to be set to the profile of a given user. Particularly around remote media, but any media that is suddenly "missing" could have this error. Enhance the pre-flight validation to ensure that the media exists, with selective conditions during the transition away from unrestricted media to either ignore the error or forbid the operation. Gracefully handle updating membership events during a profile avatar change propagation. Most of the potential errors that can be raised here should now be blocked by the pre-flight validation when setting the profile. Additionally, outgoing remote invites and room creator join membership events conditionally drop the avatar url if the media does not exist and legacy unrestricted media is disabled
2 parents b0b8a10 + 815ff8f commit d713ba0

File tree

5 files changed

+180
-16
lines changed

5 files changed

+180
-16
lines changed

synapse/handlers/profile.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,22 @@ async def validate_avatar_url(self, avatar_url: str, requester: Requester) -> No
327327
media_info = await self.hs.get_datastores().main.get_cached_remote_media(
328328
mxc_uri.server_name, mxc_uri.media_id
329329
)
330+
# When our local user has attempted to set a profile avatar to a remote
331+
# piece of media, but the local server has not actually seen it, only
332+
# complain if unrestricted media is disabled, otherwise just allow it. Later
333+
# when/if the new profile data is propagated to each room's membership
334+
# event, it will either be copied/passed along/dropped depending on the
335+
# above circumstances
330336
if not media_info:
331-
# There is no data on this, not much can be done until it comes along
337+
if self.disable_unrestricted_media:
338+
# The user should have done a COPY on this media previous to this
339+
# attempt to set
340+
raise SynapseError(
341+
HTTPStatus.NOT_FOUND,
342+
"Profile request to update avatar to remote media can not proceed, a /copy request should have happened first",
343+
errcode=Codes.NOT_FOUND,
344+
)
345+
# For backwards compatible behavior, treat the media as unrestricted
332346
return
333347

334348
if self.disable_unrestricted_media and not media_info.restricted:

synapse/handlers/room_member.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ def __init__(self, hs: "HomeServer"):
110110
self.event_auth_handler = hs.get_event_auth_handler()
111111
self._worker_lock_handler = hs.get_worker_locks_handler()
112112
self.enable_restricted_media = hs.config.experimental.msc3911_enabled
113+
self.allow_legacy_media = (
114+
not hs.config.experimental.msc3911_unrestricted_media_upload_disabled
115+
)
113116

114117
self._membership_types_to_include_profile_data_in = {
115118
Membership.JOIN,
@@ -863,21 +866,83 @@ async def update_membership_locked(
863866
logger.info("Failed to get profile information for %r: %s", target, e)
864867

865868
if self.enable_restricted_media and not media_info_for_attachment:
866-
# Other than membership
867-
avatar_url = content.get("avatar_url")
869+
# This code path should only be taken for memberships updating an
870+
# avatar url
871+
avatar_url = content.get(EventContentFields.MEMBERSHIP_AVATAR_URL)
868872
if avatar_url:
869873
# Something about the MediaRepository does not like being part of
870874
# the initialization code of the RoomMemberHandler, so just import
871875
# it on the spot instead.
872876
media_repo = self.hs.get_media_repository()
873877

874-
new_mxc_uri = await media_repo.copy_media(
875-
MXCUri.from_str(avatar_url), requester.user, 20_000
876-
)
877-
media_object = await media_repo.get_media_info(new_mxc_uri)
878-
assert isinstance(media_object, LocalMedia)
879-
media_info_for_attachment = {media_object}
880-
content[EventContentFields.MEMBERSHIP_AVATAR_URL] = str(new_mxc_uri)
878+
try:
879+
# Run a preflight that this media exists. The http replication
880+
# call should not be a thundering herd of guaranteed http fails.
881+
existing_mxc_uri = MXCUri.from_str(avatar_url)
882+
# The actual info is irrelevant at this stage, just ignore. This
883+
# will raise if the media does not exist
884+
_ = await media_repo.get_media_info(existing_mxc_uri)
885+
886+
new_mxc_uri = await media_repo.copy_media(
887+
existing_mxc_uri, requester.user, 20_000
888+
)
889+
except SynapseError:
890+
# The only kind of media copying that should fail is from a
891+
# remote item that has not been seen and cached previously. This
892+
# should already be guarded from in the only circumstances we
893+
# could think of: setting a profile avatar.
894+
#
895+
# If legacy unrestricted media is allowed, this can still be
896+
# triggered. If this happens, ignore it. This allows the avatar
897+
# url to be faithfully set to the given url. All we can do is
898+
# hope that it is not restricted when it finally shows up.
899+
# Guard against other potentials escaping by raising if it
900+
# should occur when unrestricted media begins to be disallowed.
901+
if self.allow_legacy_media:
902+
logger.debug(
903+
"Ignoring media copy request; the media is unknown and "
904+
"will not be treated as restricted"
905+
)
906+
907+
else:
908+
# Don't fail the new membership event just because the media
909+
# was not found. Specifically, for:
910+
# * Outgoing remote invites
911+
# * Joins in the context of a new room
912+
# Just remove it, and let the client deal with the lack of
913+
# hint
914+
if effective_membership_state == Membership.INVITE or (
915+
effective_membership_state == Membership.JOIN
916+
and new_room
917+
):
918+
logger.warning(
919+
"Unknown media (%s) can not be restricted to "
920+
"membership event for '%s', dropping avatar from "
921+
"event",
922+
avatar_url,
923+
target,
924+
)
925+
del content[EventContentFields.MEMBERSHIP_AVATAR_URL]
926+
927+
else:
928+
# All other types of membership should raise. For
929+
# whatever reason, the media is missing so this should
930+
# not continue
931+
logger.warning(
932+
"Unknown media (%s) can not be restricted to "
933+
"membership event for '%s'",
934+
avatar_url,
935+
target,
936+
)
937+
raise
938+
939+
else:
940+
media_object = await media_repo.get_media_info(new_mxc_uri)
941+
assert isinstance(media_object, LocalMedia)
942+
media_info_for_attachment = {media_object}
943+
content[EventContentFields.MEMBERSHIP_AVATAR_URL] = str(
944+
new_mxc_uri
945+
)
881946

882947
# if this is a join with a 3pid signature, we may need to turn a 3pid
883948
# invite into a normal invite before we can handle the join.

synapse/media/media_repository.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,13 @@ async def get_media_info(self, mxc_uri: MXCUri) -> Union[LocalMedia, RemoteMedia
153153
mxc_uri.server_name, mxc_uri.media_id
154154
)
155155
if not media_info:
156-
raise SynapseError(404, "Media not found", errcode="M_NOT_FOUND")
156+
raise SynapseError(
157+
HTTPStatus.NOT_FOUND, "Media not found", errcode=Codes.NOT_FOUND
158+
)
157159
if media_info.quarantined_by:
158-
raise SynapseError(404, "Media not found", errcode="M_NOT_FOUND")
160+
raise SynapseError(
161+
HTTPStatus.NOT_FOUND, "Media not found", errcode=Codes.NOT_FOUND
162+
)
159163
return media_info
160164

161165
async def create_or_update_content(

tests/rest/client/test_profile.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1015,7 +1015,7 @@ def test_can_attach_media_to_profile_update(self) -> None:
10151015
assert restrictions.event_id is None
10161016
assert restrictions.profile_user_id == UserID.from_string(self.user)
10171017

1018-
def test_attaching_nonexistent_media_to_profile_fails(self) -> None:
1018+
def test_attaching_nonexistent_local_media_to_profile_fails(self) -> None:
10191019
"""
10201020
Test that media that does not exist is not allowed to be attached to a user profile.
10211021
"""
@@ -1032,6 +1032,42 @@ def test_attaching_nonexistent_media_to_profile_fails(self) -> None:
10321032
assert channel.json_body["errcode"] == Codes.INVALID_PARAM
10331033
assert "does not exist" in channel.json_body["error"]
10341034

1035+
def test_attaching_unreachable_remote_media_to_profile_might_succeed(self) -> None:
1036+
"""
1037+
Test that media that can not be retrieved can be attached to a user profile, if
1038+
legacy unrestricted media is allowed.
1039+
"""
1040+
# Generate non-existing media.
1041+
nonexistent_mxc_uri = MXCUri.from_str("mxc://remote/fakeMediaId")
1042+
channel = self.make_request(
1043+
"PUT",
1044+
f"/_matrix/client/v3/profile/{self.user}/avatar_url",
1045+
access_token=self.tok,
1046+
content={"avatar_url": str(nonexistent_mxc_uri)},
1047+
)
1048+
1049+
assert channel.code == HTTPStatus.OK, channel.json_body
1050+
1051+
@override_config(
1052+
{"experimental_features": {"msc3911_unrestricted_media_upload_disabled": True}}
1053+
)
1054+
def test_attaching_unreachable_remote_media_to_profile_fails(self) -> None:
1055+
"""
1056+
Test that media that can not be retrieved will fail to be attached to a user
1057+
profile when legacy unrestricted media is disabled.
1058+
"""
1059+
# Generate non-existing media.
1060+
nonexistent_mxc_uri = MXCUri.from_str("mxc://remote/fakeMediaId_2")
1061+
channel = self.make_request(
1062+
"PUT",
1063+
f"/_matrix/client/v3/profile/{self.user}/avatar_url",
1064+
access_token=self.tok,
1065+
content={"avatar_url": str(nonexistent_mxc_uri)},
1066+
)
1067+
1068+
assert channel.code == HTTPStatus.NOT_FOUND, channel.json_body
1069+
assert channel.json_body["errcode"] == Codes.NOT_FOUND
1070+
10351071
def test_attaching_unrestricted_media_to_profile(self) -> None:
10361072
"""
10371073
Test that attaching unrestricted media to user profile also works

tests/rest/client/test_rooms.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5269,6 +5269,53 @@ def test_create_room_fails_with_malformed_room_avatar_url(self) -> None:
52695269
room_id = self.create_room_with_avatar(avatar_mxc="junk", expected_code=400)
52705270
assert room_id is None
52715271

5272+
@override_config(
5273+
{"experimental_features": {"msc3911_unrestricted_media_upload_disabled": True}}
5274+
)
5275+
def test_create_room_with_missing_profile_avatar_media_succeeds(self) -> None:
5276+
"""
5277+
Test that a profile avatar that should automatically be included in a room
5278+
creator's join event does not break the room when the actual media of the avatar
5279+
is missing.
5280+
"""
5281+
# First inject a profile avatar url directly into the database. The handler
5282+
# functions for such can not be used as they do validation, and it would fail as
5283+
# the media does not actually exist.
5284+
avatar_mxc_uri = MXCUri.from_str("mxc://fake-domain/whatever")
5285+
# Make sure to add the restrictions too
5286+
self.get_success_or_raise(
5287+
self.hs.get_datastores().main.set_media_restricted_to_user_profile(
5288+
avatar_mxc_uri.server_name,
5289+
avatar_mxc_uri.media_id,
5290+
self.user,
5291+
)
5292+
)
5293+
self.get_success_or_raise(
5294+
self.store.set_profile_avatar_url(
5295+
UserID.from_string(self.user), str(avatar_mxc_uri)
5296+
)
5297+
)
5298+
5299+
# try and create room. This should succeed, but the avatar will have been
5300+
# stripped from the join event of the creator
5301+
room_id = self.helper.create_room_as(
5302+
self.user,
5303+
tok=self.tok,
5304+
)
5305+
assert room_id is not None
5306+
# Make sure the avatar is not on the event
5307+
membership_as_set = self.get_success_or_raise(
5308+
self.store.get_membership_event_ids_for_user(self.user, room_id)
5309+
)
5310+
join_event_id = membership_as_set.pop()
5311+
join_event = self.get_success_or_raise(self.store.get_event(join_event_id))
5312+
assert join_event.content["membership"] == Membership.JOIN
5313+
assert "avatar_url" not in join_event.content
5314+
5315+
# The display name would have been added, see if that is still there
5316+
assert "displayname" in join_event.content
5317+
assert join_event.content["displayname"] == "david"
5318+
52725319

52735320
class RoomMemberEventMediaAttachmentTestCase(unittest.HomeserverTestCase):
52745321
servlets = [
@@ -5489,8 +5536,6 @@ def test_invite_with_media_get_copied_and_attached_to_event(self) -> None:
54895536
assert channel.code == 200, channel.result["body"]
54905537

54915538
# Get the member event of the invite just occurred.
5492-
# creator_event_ids = self.get_success(self.store.get_membership_event_ids_for_user(self.user, room_id))
5493-
# assert len(creator_event_ids) == 1
54945539
invitee_event_ids = self.get_success(
54955540
self.store.get_membership_event_ids_for_user(self.other_user, room_id)
54965541
)
@@ -5502,7 +5547,7 @@ def test_invite_with_media_get_copied_and_attached_to_event(self) -> None:
55025547
assert event.type == EventTypes.Member
55035548

55045549
# Verify that this event a different mxc
5505-
assert event.content.get(EventContentFields.MEMBERSHIP_DISPLAYNAME) != str(
5550+
assert event.content.get(EventContentFields.MEMBERSHIP_AVATAR_URL) != str(
55065551
invitee_avatar_mxc_uri
55075552
)
55085553

0 commit comments

Comments
 (0)