Skip to content

Commit 815ff8f

Browse files
committed
fix(MSC3911): Handle profile avatars that are unknown gracefully
1 parent b0b8a10 commit 815ff8f

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)