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

Commit bf60da1

Browse files
authored
Configurable limits on avatars (#11846)
Only allow files which file size and content types match configured limits to be set as avatar. Most of the inspiration from the non-test code comes from matrix-org/synapse-dinsic#19
1 parent 6d482ba commit bf60da1

File tree

7 files changed

+363
-2
lines changed

7 files changed

+363
-2
lines changed

changelog.d/11846.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow configuring a maximum file size as well as a list of allowed content types for avatars.

docs/sample_config.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,20 @@ limit_remote_rooms:
471471
#
472472
#allow_per_room_profiles: false
473473

474+
# The largest allowed file size for a user avatar. Defaults to no restriction.
475+
#
476+
# Note that user avatar changes will not work if this is set without
477+
# using Synapse's media repository.
478+
#
479+
#max_avatar_size: 10M
480+
481+
# The MIME types allowed for user avatars. Defaults to no restriction.
482+
#
483+
# Note that user avatar changes will not work if this is set without
484+
# using Synapse's media repository.
485+
#
486+
#allowed_avatar_mimetypes: ["image/png", "image/jpeg", "image/gif"]
487+
474488
# How long to keep redacted events in unredacted form in the database. After
475489
# this period redacted events get replaced with their redacted form in the DB.
476490
#

synapse/config/server.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,19 @@ def read_config(self, config, **kwargs):
489489
# events with profile information that differ from the target's global profile.
490490
self.allow_per_room_profiles = config.get("allow_per_room_profiles", True)
491491

492+
# The maximum size an avatar can have, in bytes.
493+
self.max_avatar_size = config.get("max_avatar_size")
494+
if self.max_avatar_size is not None:
495+
self.max_avatar_size = self.parse_size(self.max_avatar_size)
496+
497+
# The MIME types allowed for an avatar.
498+
self.allowed_avatar_mimetypes = config.get("allowed_avatar_mimetypes")
499+
if self.allowed_avatar_mimetypes and not isinstance(
500+
self.allowed_avatar_mimetypes,
501+
list,
502+
):
503+
raise ConfigError("allowed_avatar_mimetypes must be a list")
504+
492505
self.listeners = [parse_listener_def(x) for x in config.get("listeners", [])]
493506

494507
# no_tls is not really supported any more, but let's grandfather it in
@@ -1168,6 +1181,20 @@ def generate_config_section(
11681181
#
11691182
#allow_per_room_profiles: false
11701183
1184+
# The largest allowed file size for a user avatar. Defaults to no restriction.
1185+
#
1186+
# Note that user avatar changes will not work if this is set without
1187+
# using Synapse's media repository.
1188+
#
1189+
#max_avatar_size: 10M
1190+
1191+
# The MIME types allowed for user avatars. Defaults to no restriction.
1192+
#
1193+
# Note that user avatar changes will not work if this is set without
1194+
# using Synapse's media repository.
1195+
#
1196+
#allowed_avatar_mimetypes: ["image/png", "image/jpeg", "image/gif"]
1197+
11711198
# How long to keep redacted events in unredacted form in the database. After
11721199
# this period redacted events get replaced with their redacted form in the DB.
11731200
#

synapse/handlers/profile.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
create_requester,
3232
get_domain_from_id,
3333
)
34+
from synapse.util.caches.descriptors import cached
35+
from synapse.util.stringutils import parse_and_validate_mxc_uri
3436

3537
if TYPE_CHECKING:
3638
from synapse.server import HomeServer
@@ -64,6 +66,11 @@ def __init__(self, hs: "HomeServer"):
6466
self.user_directory_handler = hs.get_user_directory_handler()
6567
self.request_ratelimiter = hs.get_request_ratelimiter()
6668

69+
self.max_avatar_size = hs.config.server.max_avatar_size
70+
self.allowed_avatar_mimetypes = hs.config.server.allowed_avatar_mimetypes
71+
72+
self.server_name = hs.config.server.server_name
73+
6774
if hs.config.worker.run_background_tasks:
6875
self.clock.looping_call(
6976
self._update_remote_profile_cache, self.PROFILE_UPDATE_MS
@@ -286,6 +293,9 @@ async def set_avatar_url(
286293
400, "Avatar URL is too long (max %i)" % (MAX_AVATAR_URL_LEN,)
287294
)
288295

296+
if not await self.check_avatar_size_and_mime_type(new_avatar_url):
297+
raise SynapseError(403, "This avatar is not allowed", Codes.FORBIDDEN)
298+
289299
avatar_url_to_set: Optional[str] = new_avatar_url
290300
if new_avatar_url == "":
291301
avatar_url_to_set = None
@@ -307,6 +317,63 @@ async def set_avatar_url(
307317

308318
await self._update_join_states(requester, target_user)
309319

320+
@cached()
321+
async def check_avatar_size_and_mime_type(self, mxc: str) -> bool:
322+
"""Check that the size and content type of the avatar at the given MXC URI are
323+
within the configured limits.
324+
325+
Args:
326+
mxc: The MXC URI at which the avatar can be found.
327+
328+
Returns:
329+
A boolean indicating whether the file can be allowed to be set as an avatar.
330+
"""
331+
if not self.max_avatar_size and not self.allowed_avatar_mimetypes:
332+
return True
333+
334+
server_name, _, media_id = parse_and_validate_mxc_uri(mxc)
335+
336+
if server_name == self.server_name:
337+
media_info = await self.store.get_local_media(media_id)
338+
else:
339+
media_info = await self.store.get_cached_remote_media(server_name, media_id)
340+
341+
if media_info is None:
342+
# Both configuration options need to access the file's metadata, and
343+
# retrieving remote avatars just for this becomes a bit of a faff, especially
344+
# if e.g. the file is too big. It's also generally safe to assume most files
345+
# used as avatar are uploaded locally, or if the upload didn't happen as part
346+
# of a PUT request on /avatar_url that the file was at least previewed by the
347+
# user locally (and therefore downloaded to the remote media cache).
348+
logger.warning("Forbidding avatar change to %s: avatar not on server", mxc)
349+
return False
350+
351+
if self.max_avatar_size:
352+
# Ensure avatar does not exceed max allowed avatar size
353+
if media_info["media_length"] > self.max_avatar_size:
354+
logger.warning(
355+
"Forbidding avatar change to %s: %d bytes is above the allowed size "
356+
"limit",
357+
mxc,
358+
media_info["media_length"],
359+
)
360+
return False
361+
362+
if self.allowed_avatar_mimetypes:
363+
# Ensure the avatar's file type is allowed
364+
if (
365+
self.allowed_avatar_mimetypes
366+
and media_info["media_type"] not in self.allowed_avatar_mimetypes
367+
):
368+
logger.warning(
369+
"Forbidding avatar change to %s: mimetype %s not allowed",
370+
mxc,
371+
media_info["media_type"],
372+
)
373+
return False
374+
375+
return True
376+
310377
async def on_profile_query(self, args: JsonDict) -> JsonDict:
311378
"""Handles federation profile query requests."""
312379

synapse/handlers/room_member.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,12 @@ async def update_membership_locked(
590590
errcode=Codes.BAD_JSON,
591591
)
592592

593+
if "avatar_url" in content:
594+
if not await self.profile_handler.check_avatar_size_and_mime_type(
595+
content["avatar_url"],
596+
):
597+
raise SynapseError(403, "This avatar is not allowed", Codes.FORBIDDEN)
598+
593599
# The event content should *not* include the authorising user as
594600
# it won't be properly signed. Strip it out since it might come
595601
# back from a client updating a display name / avatar.

tests/handlers/test_profile.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
14+
from typing import Any, Dict
1515
from unittest.mock import Mock
1616

1717
import synapse.types
1818
from synapse.api.errors import AuthError, SynapseError
1919
from synapse.rest import admin
20+
from synapse.server import HomeServer
2021
from synapse.types import UserID
2122

2223
from tests import unittest
@@ -46,7 +47,7 @@ def register_query_handler(query_type, handler):
4647
)
4748
return hs
4849

49-
def prepare(self, reactor, clock, hs):
50+
def prepare(self, reactor, clock, hs: HomeServer):
5051
self.store = hs.get_datastore()
5152

5253
self.frank = UserID.from_string("@1234abcd:test")
@@ -248,3 +249,92 @@ def test_set_my_avatar_if_disabled(self):
248249
),
249250
SynapseError,
250251
)
252+
253+
def test_avatar_constraints_no_config(self):
254+
"""Tests that the method to check an avatar against configured constraints skips
255+
all of its check if no constraint is configured.
256+
"""
257+
# The first check that's done by this method is whether the file exists; if we
258+
# don't get an error on a non-existing file then it means all of the checks were
259+
# successfully skipped.
260+
res = self.get_success(
261+
self.handler.check_avatar_size_and_mime_type("mxc://test/unknown_file")
262+
)
263+
self.assertTrue(res)
264+
265+
@unittest.override_config({"max_avatar_size": 50})
266+
def test_avatar_constraints_missing(self):
267+
"""Tests that an avatar isn't allowed if the file at the given MXC URI couldn't
268+
be found.
269+
"""
270+
res = self.get_success(
271+
self.handler.check_avatar_size_and_mime_type("mxc://test/unknown_file")
272+
)
273+
self.assertFalse(res)
274+
275+
@unittest.override_config({"max_avatar_size": 50})
276+
def test_avatar_constraints_file_size(self):
277+
"""Tests that a file that's above the allowed file size is forbidden but one
278+
that's below it is allowed.
279+
"""
280+
self._setup_local_files(
281+
{
282+
"small": {"size": 40},
283+
"big": {"size": 60},
284+
}
285+
)
286+
287+
res = self.get_success(
288+
self.handler.check_avatar_size_and_mime_type("mxc://test/small")
289+
)
290+
self.assertTrue(res)
291+
292+
res = self.get_success(
293+
self.handler.check_avatar_size_and_mime_type("mxc://test/big")
294+
)
295+
self.assertFalse(res)
296+
297+
@unittest.override_config({"allowed_avatar_mimetypes": ["image/png"]})
298+
def test_avatar_constraint_mime_type(self):
299+
"""Tests that a file with an unauthorised MIME type is forbidden but one with
300+
an authorised content type is allowed.
301+
"""
302+
self._setup_local_files(
303+
{
304+
"good": {"mimetype": "image/png"},
305+
"bad": {"mimetype": "application/octet-stream"},
306+
}
307+
)
308+
309+
res = self.get_success(
310+
self.handler.check_avatar_size_and_mime_type("mxc://test/good")
311+
)
312+
self.assertTrue(res)
313+
314+
res = self.get_success(
315+
self.handler.check_avatar_size_and_mime_type("mxc://test/bad")
316+
)
317+
self.assertFalse(res)
318+
319+
def _setup_local_files(self, names_and_props: Dict[str, Dict[str, Any]]):
320+
"""Stores metadata about files in the database.
321+
322+
Args:
323+
names_and_props: A dictionary with one entry per file, with the key being the
324+
file's name, and the value being a dictionary of properties. Supported
325+
properties are "mimetype" (for the file's type) and "size" (for the
326+
file's size).
327+
"""
328+
store = self.hs.get_datastore()
329+
330+
for name, props in names_and_props.items():
331+
self.get_success(
332+
store.store_local_media(
333+
media_id=name,
334+
media_type=props.get("mimetype", "image/png"),
335+
time_now_ms=self.clock.time_msec(),
336+
upload_name=None,
337+
media_length=props.get("size", 50),
338+
user_id=UserID.from_string("@rin:test"),
339+
)
340+
)

0 commit comments

Comments
 (0)