Repository: matrix-org/synapse

Issue:
Deactivating account with `{erase: true}` fails with internal server error
<!--

**THIS IS NOT A SUPPORT CHANNEL!**
**IF YOU HAVE SUPPORT QUESTIONS ABOUT RUNNING OR CONFIGURING YOUR OWN HOME SERVER**,
please ask in **#synapse:matrix.org** (using a matrix.org account if necessary)

If you want to report a security issue, please see https://matrix.org/security-disclosure-policy/

This is a bug report template. By following the instructions below and
filling out the sections with your information, you will help the us to get all
the necessary data to fix your issue.

You can also preview your report before submitting it. You may remove sections
that aren't relevant to your particular case.

Text between <!-- and --​> marks will be invisible in the report.

-->

### Description

<!-- Describe here the problem that you are experiencing -->
When attempting GDPR-compliant user deactivation using `POST /_synapse/admin/v1/deactivate/@user:domain.tld`  with `{erase: true}`, the request fails with `M_UNKNOWN` and `ValueError: mxc URI '' did not match expected format` appears in the homeserver log file. Without the `erase` parameter, deletion works.

Interestingly, when deleting multiple accounts in a row with `{erase: true}`, they are not kicked from their rooms, but as soon as we then delete a single additional user with `{erase: false}`, the user parter kicks in and immediately also kicks *all* the users for which deletion previously failed.

I'm unsure whether this is relevant, but some details about our setup:
- We run https://github.com/matrix-org/matrix-synapse-ldap3 against OpenLDAP as our authentication backend
- All accounts are provisioned using using the user creation/modification admin API and do not have a password set in Synapse (since authentication is done against LDAP)
- When a user is removed from our organization, our backend tools delete the account both in Synapse and in LDAP (We've tested that ordering makes no difference, i.e., the error doesn't disappear if we keep the LDAP entry around until Synapse is done with the deactivation)

### Steps to reproduce

- provision a user account through the admin API with no password (we set just `displayname` and the email address in the POST body)
- (you may log in as that user, or don't, this appears to make no difference)
- use the deactivate account API with `{erase: true}` to deactivate the account
- see that the request fails and that you can still log in with that user account (although all active sessions were terminated), and the account is still member of its rooms

<!--
Describe how what happens differs from what you expected.

If you can identify any relevant log snippets from _homeserver.log_, please include
those (please be careful to remove any personal or private data). Please surround them with
``` (three backticks, on a line on their own), so that they are formatted legibly.
-->


<details>
<summary>Server logs showing two deletion failures with the erase option and one success without it</summary>

```
2022-03-20 17:16:25,248 - synapse.http.server - 100 - ERROR - POST-40008 - Failed handle request via 'DeactivateAccountRestServlet': <XForwardedForRequest at 0x7fb22425afd0 method='POST' uri='/_synapse/admin/v1/deactivate/@testaccount1:domain.tld' clientproto='HTTP/1.0' site='8008'>
Traceback (most recent call last):
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/util/caches/descriptors.py", line 313, in _wrapped
    ret = cache.get(cache_key, callback=invalidate_callback)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/util/caches/deferred_cache.py", line 176, in get
    raise KeyError()
KeyError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/http/server.py", line 269, in _async_render_wrapper
    callback_return = await self._async_render(request)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/http/server.py", line 471, in _async_render
    callback_return = await raw_callback_return
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/rest/admin/users.py", line 616, in on_POST
    result = await self._deactivate_account_handler.deactivate_account(
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/handlers/deactivate_account.py", line 139, in deactivate_account
    await self._profile_handler.set_avatar_url(
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/handlers/profile.py", line 306, in set_avatar_url
    if not await self.check_avatar_size_and_mime_type(new_avatar_url):
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/twisted/internet/defer.py", line 1660, in _inlineCallbacks
    result = current_context.run(gen.send, result)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/handlers/profile.py", line 348, in check_avatar_size_and_mime_type
    server_name, _, media_id = parse_and_validate_mxc_uri(mxc)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/util/stringutils.py", line 188, in parse_and_validate_mxc_uri
    raise ValueError("mxc URI %r did not match expected format" % (mxc,))
ValueError: mxc URI '' did not match expected format
2022-03-20 17:16:25,261 - synapse.access.http.8008 - 427 - INFO - POST-40008 - 127.0.0.1 - 8008 - {@backendbot:domain.tld} Processed request: 0.169sec/0.001sec (0.044sec, 0.005sec) (0.012sec/0.101sec/17) 55B 500 "POST /_synapse/admin/v1/deactivate/@testaccount1:domain.tld HTTP/1.0" "python-requests/2.26.0" [0 dbevts]
2022-03-20 17:19:12,819 - synapse.storage.databases.main.event_push_actions - 608 - INFO - event_push_action_stream_orderings-561 - Searching for stream ordering 1 month ago
2022-03-20 17:19:12,840 - synapse.storage.databases.main.event_push_actions - 612 - INFO - event_push_action_stream_orderings-561 - Found stream ordering 1 month ago: it's 14461
2022-03-20 17:19:12,840 - synapse.storage.databases.main.event_push_actions - 615 - INFO - event_push_action_stream_orderings-561 - Searching for stream ordering 1 day ago
2022-03-20 17:19:12,854 - synapse.storage.databases.main.event_push_actions - 619 - INFO - event_push_action_stream_orderings-561 - Found stream ordering 1 day ago: it's 14619
2022-03-20 17:26:34,757 - synapse.http.server - 100 - ERROR - POST-40121 - Failed handle request via 'DeactivateAccountRestServlet': <XForwardedForRequest at 0x7fb20c7bed30 method='POST' uri='/_synapse/admin/v1/deactivate/@testaccount2:domain.tld' clientproto='HTTP/1.0' site='8008'>
Traceback (most recent call last):
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/util/caches/descriptors.py", line 313, in _wrapped
    ret = cache.get(cache_key, callback=invalidate_callback)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/util/caches/deferred_cache.py", line 176, in get
    raise KeyError()
KeyError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/http/server.py", line 269, in _async_render_wrapper
    callback_return = await self._async_render(request)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/http/server.py", line 471, in _async_render
    callback_return = await raw_callback_return
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/rest/admin/users.py", line 616, in on_POST
    result = await self._deactivate_account_handler.deactivate_account(
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/handlers/deactivate_account.py", line 139, in deactivate_account
    await self._profile_handler.set_avatar_url(
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/handlers/profile.py", line 306, in set_avatar_url
    if not await self.check_avatar_size_and_mime_type(new_avatar_url):
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/twisted/internet/defer.py", line 1660, in _inlineCallbacks
    result = current_context.run(gen.send, result)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/handlers/profile.py", line 348, in check_avatar_size_and_mime_type
    server_name, _, media_id = parse_and_validate_mxc_uri(mxc)
  File "/opt/venvs/matrix-synapse/lib/python3.8/site-packages/synapse/util/stringutils.py", line 188, in parse_and_validate_mxc_uri
    raise ValueError("mxc URI %r did not match expected format" % (mxc,))
ValueError: mxc URI '' did not match expected format
2022-03-20 17:26:34,762 - synapse.access.http.8008 - 427 - INFO - POST-40121 - 127.0.0.1 - 8008 - {@backendbot:domain.tld} Processed request: 0.064sec/0.001sec (0.016sec, 0.004sec) (0.006sec/0.037sec/12) 55B 500 "POST /_synapse/admin/v1/deactivate/@testaccount2:domain.tld HTTP/1.0" "python-requests/2.26.0" [0 dbevts]
2022-03-20 17:26:39,320 - synapse.handlers.deactivate_account - 220 - INFO - user_parter_loop-1 - Starting user parter
2022-03-20 17:26:39,329 - synapse.handlers.deactivate_account - 226 - INFO - user_parter_loop-1 - User parter parting '@testaccount1:domain.tld'
2022-03-20 17:26:39,329 - synapse.handlers.deactivate_account - 240 - INFO - user_parter_loop-1 - User parter parting '@testaccount1:domain.tld' from '!<redacted>:domain.tld'
2022-03-20 17:26:39,553 - synapse.handlers.deactivate_account - 196 - INFO - POST-40127 - Rejected invite for deactivated user '@testaccount2:domain.tld' in room '!<redacted>:domain.tld'
2022-03-20 17:26:39,623 - synapse.handlers.deactivate_account - 240 - INFO - user_parter_loop-1 - User parter parting '@testaccount1:domain.tld' from '!<redacted>:domain.tld'
2022-03-20 17:26:39,847 - synapse.handlers.deactivate_account - 196 - INFO - POST-40127 - Rejected invite for deactivated user '@testaccount2:domain.tld' in room '!<redacted>:domain.tld'
2022-03-20 17:26:39,972 - synapse.handlers.deactivate_account - 240 - INFO - user_parter_loop-1 - User parter parting '@testaccount1:domain.tld' from '!<redacted>:domain.tld'
2022-03-20 17:26:40,011 - synapse.access.http.8008 - 427 - INFO - POST-40127 - 127.0.0.1 - 8008 - {@backendbot:domain.tld} Processed request: 0.726sec/0.016sec (0.040sec, 0.027sec) (0.053sec/0.229sec/30) 37B 200 "POST /_synapse/admin/v1/deactivate/@testaccount2:domain.tld HTTP/1.0" "python-requests/2.26.0" [0 dbevts]
2022-03-20 17:26:40,169 - synapse.handlers.deactivate_account - 240 - INFO - user_parter_loop-1 - User parter parting '@testaccount1:domain.tld' from '!<redacted>:domain.tld'
2022-03-20 17:26:40,412 - synapse.handlers.deactivate_account - 229 - INFO - user_parter_loop-1 - User parter finished parting '@testaccount1:domain.tld'
2022-03-20 17:26:40,420 - synapse.handlers.deactivate_account - 226 - INFO - user_parter_loop-1 - User parter parting '@testaccount2:domain.tld'
2022-03-20 17:26:40,446 - synapse.handlers.deactivate_account - 229 - INFO - user_parter_loop-1 - User parter finished parting '@testaccount2:domain.tld'
2022-03-20 17:26:40,451 - synapse.handlers.deactivate_account - 230 - INFO - user_parter_loop-1 - User parter finished: stopping
```
</details>

### Version information

<!-- IMPORTANT: please answer the following questions, to help us narrow down the problem -->

<!-- Was this issue identified on matrix.org or another homeserver? -->
- **Homeserver**: (withheld)

If not matrix.org:

<!--
 What version of Synapse is running?

You can find the Synapse version with this command:

$ curl http://localhost:8008/_synapse/admin/v1/server_version

(You may need to replace `localhost:8008` if Synapse is not configured to
listen on that port.)
-->
- **Version**: 1.54.0 on Python 3.8.10

- **Install method**: debian package `matrix-synapse-py3 1.54.0+focal1`
<!-- examples: package manager/git clone/pip  -->

- **Platform**: Ubuntu Server 20.04.4 LTS, VM
<!--
Tell us about the environment in which your homeserver is operating
distro, hardware, if it's running in a vm/container, etc.
-->



Discussion:
IMO it was introduced in #11846 
If you set `erase: true` avatar url is an empty string `""` and the `parse_and_validate_mxc_uri` fails.

But it is not clear why this test not fails:
https://github.com/matrix-org/synapse/blob/bf9d549e3ad944e1e53a2ecc898640d690bf1eac/tests/rest/admin/test_user.py#L1008
> But it is not clear why this test not fails:

Because it only tests a valid MXC URI, and a case where it's `None`, not where its `""`
Thanks @RenWal for a very thorough report.

To my mind, the root cause here is having two ways of representing "this user doesn't have an avatar set". The database allows `profiles.displayname` and `profiles.avatar_url` to be nullable strings. I don't see a meaningful distinction between the empty string and a NULL value here.

A point fix would be to only call `check_avatar_size_and_mime_type` if `new_avatar_url` is truthy here.

https://github.com/matrix-org/synapse/blob/300ed0b8a6050b5187a2a524a82cf87baad3ca73/synapse/handlers/profile.py#L306-L307
@RenWal: as a potential workaround, it might be possible to use [the admin API](https://matrix-org.github.io/synapse/latest/admin_api/user_admin_api.html#create-or-modify-account) to set a user's avatar url to `null`, and then deactivating their account with erasure as you tried previously. Would you mind trying that to see if it works?

(I agree we should fix the bug in the meantime.)
Also note: to reproduce this one must either set `max_avatar_size` or `allowed_avatar_mimetypes` in Synapse's config.

Resolving patch:
diff --git a/synapse/handlers/profile.py b/synapse/handlers/profile.py
--- a/synapse/handlers/profile.py
+++ b/synapse/handlers/profile.py
@@ -336,12 +336,18 @@ async def check_avatar_size_and_mime_type(self, mxc: str) -> bool:
         """Check that the size and content type of the avatar at the given MXC URI are
         within the configured limits.
 
+        If the given `mxc` is empty, no checks are performed. (Users are always able to
+        unset their avatar.)
+
         Args:
             mxc: The MXC URI at which the avatar can be found.
 
         Returns:
              A boolean indicating whether the file can be allowed to be set as an avatar.
         """
+        if mxc == "":
+            return True
+
         if not self.max_avatar_size and not self.allowed_avatar_mimetypes:
             return True
 


Test patch:
diff --git a/tests/handlers/test_profile.py b/tests/handlers/test_profile.py
--- a/tests/handlers/test_profile.py
+++ b/tests/handlers/test_profile.py
@@ -267,6 +267,12 @@ def test_avatar_constraints_no_config(self) -> None:
         )
         self.assertTrue(res)
 
+    @unittest.override_config({"max_avatar_size": 50})
+    def test_avatar_constraints_allow_empty_avatar_url(self) -> None:
+        """An empty avatar is always permitted."""
+        res = self.get_success(self.handler.check_avatar_size_and_mime_type(""))
+        self.assertTrue(res)
+
     @unittest.override_config({"max_avatar_size": 50})
     def test_avatar_constraints_missing(self) -> None:
         """Tests that an avatar isn't allowed if the file at the given MXC URI couldn't
diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py
--- a/tests/rest/admin/test_user.py
+++ b/tests/rest/admin/test_user.py
@@ -1050,6 +1050,25 @@ def test_deactivate_user_erase_true(self) -> None:
 
         self._is_erased("@user:test", True)
 
+    @override_config({"max_avatar_size": 1234})
+    def test_deactivate_user_erase_true_avatar_nonnull_but_empty(self) -> None:
+        """Check we can erase a user whose avatar is the empty string.
+
+        Reproduces #12257.
+        """
+        # Patch `self.other_user` to have an empty string as their avatar.
+        self.get_success(self.store.set_profile_avatar_url("user", ""))
+
+        # Check we can still erase them.
+        channel = self.make_request(
+            "POST",
+            self.url,
+            access_token=self.admin_user_tok,
+            content={"erase": True},
+        )
+        self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
+        self._is_erased("@user:test", True)
+
     def test_deactivate_user_erase_false(self) -> None:
         """
         Test deactivating a user and set `erase` to `false`