Repository: matrix-org/synapse

Issue:
Unexplained `KeyError  'destination_is'`
Internal users: https://sentry.matrix.org/sentry/synapse-matrixorg/issues/247960/?query=is%3Aunresolved%20destination_is

```
StopIteration: DestinationRetryTimings(failure_ts=1568794598907, retry_last_ts=1591706354703, retry_interval=126355483270)
  File "twisted/internet/defer.py", line 1660, in _inlineCallbacks
    result = current_context.run(gen.send, result)

StopIteration: DestinationRetryTimings(failure_ts=1568794598907, retry_last_ts=1591706354703, retry_interval=126355483270)
  File "twisted/internet/defer.py", line 1660, in _inlineCallbacks
    result = current_context.run(gen.send, result)

KeyError: 'destination_is'
  File "synapse/http/server.py", line 298, in _async_render_wrapper
    callback_return = await self._async_render(request)
  File "synapse/http/server.py", line 500, in _async_render
    callback_return = await raw_callback_return
  File "synapse/rest/client/directory.py", line 57, in on_GET
    res = await self.directory_handler.get_association(room_alias_obj)
  File "synapse/handlers/directory.py", line 257, in get_association
    ignore_backoff=True,
  File "synapse/federation/federation_client.py", line 184, in make_query
    ignore_backoff=ignore_backoff,
  File "synapse/federation/transport/client.py", line 272, in make_query
    ignore_backoff=ignore_backoff,
  File "synapse/http/matrixfederationclient.py", line 1068, in get_json
    timeout=timeout,
  File "synapse/http/matrixfederationclient.py", line 389, in _send_request_with_optional_trailing_slash
    response = await self._send_request(request, **send_request_args)
  File "synapse/http/matrixfederationclient.py", line 540, in _send_request
    destination_bytes, method_bytes, url_to_sign_bytes
  File "synapse/http/matrixfederationclient.py", line 748, in build_auth_headers
    request.get("destination") or request["destination_is"],
```

https://github.com/matrix-org/synapse/blob/8dd3e0e084304dfc02ff072a1beaed5266cf4e33/synapse/http/matrixfederationclient.py#L695-L752

There's logic which is trying to assert we've provided sensible input, but it's failing. I'd guess that `destination` is an empty `bytes` object?


Discussion:
Yes that seems to be the problem.
I got that error when I tried to `GET /_matrix/client/r0/profile/{userId}` on my server.
I used it wrong and didn't put the domain in the `{userId}`. (so just `/%40user%3A` and not `/%40user%3Aexample.com`
That caused `destination` not to be `None`, but an empty object.

Not sure if this is helpful in any way, but it was my evening. So there you go.

Resolving patch:
diff --git a/synapse/http/matrixfederationclient.py b/synapse/http/matrixfederationclient.py
--- a/synapse/http/matrixfederationclient.py
+++ b/synapse/http/matrixfederationclient.py
@@ -731,8 +731,11 @@ def build_auth_headers(
         Returns:
             A list of headers to be added as "Authorization:" headers
         """
-        if destination is None and destination_is is None:
-            raise ValueError("destination and destination_is cannot both be None!")
+        if not destination and not destination_is:
+            raise ValueError(
+                "At least one of the arguments destination and destination_is "
+                "must be a nonempty bytestring."
+            )
 
         request: JsonDict = {
             "method": method.decode("ascii"),
diff --git a/synapse/rest/client/profile.py b/synapse/rest/client/profile.py
--- a/synapse/rest/client/profile.py
+++ b/synapse/rest/client/profile.py
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 """ This module contains REST servlets to do with profile: /profile/<paths> """
-
+from http import HTTPStatus
 from typing import TYPE_CHECKING, Tuple
 
 from synapse.api.errors import Codes, SynapseError
@@ -45,8 +45,12 @@ async def on_GET(
             requester = await self.auth.get_user_by_req(request)
             requester_user = requester.user
 
-        user = UserID.from_string(user_id)
+        if not UserID.is_valid(user_id):
+            raise SynapseError(
+                HTTPStatus.BAD_REQUEST, "Invalid user id", Codes.INVALID_PARAM
+            )
 
+        user = UserID.from_string(user_id)
         await self.profile_handler.check_profile_query_allowed(user, requester_user)
 
         displayname = await self.profile_handler.get_displayname(user)
@@ -98,8 +102,12 @@ async def on_GET(
             requester = await self.auth.get_user_by_req(request)
             requester_user = requester.user
 
-        user = UserID.from_string(user_id)
+        if not UserID.is_valid(user_id):
+            raise SynapseError(
+                HTTPStatus.BAD_REQUEST, "Invalid user id", Codes.INVALID_PARAM
+            )
 
+        user = UserID.from_string(user_id)
         await self.profile_handler.check_profile_query_allowed(user, requester_user)
 
         avatar_url = await self.profile_handler.get_avatar_url(user)
@@ -150,8 +158,12 @@ async def on_GET(
             requester = await self.auth.get_user_by_req(request)
             requester_user = requester.user
 
-        user = UserID.from_string(user_id)
+        if not UserID.is_valid(user_id):
+            raise SynapseError(
+                HTTPStatus.BAD_REQUEST, "Invalid user id", Codes.INVALID_PARAM
+            )
 
+        user = UserID.from_string(user_id)
         await self.profile_handler.check_profile_query_allowed(user, requester_user)
 
         displayname = await self.profile_handler.get_displayname(user)
diff --git a/synapse/types.py b/synapse/types.py
--- a/synapse/types.py
+++ b/synapse/types.py
@@ -267,7 +267,6 @@ def from_string(cls: Type[DS], s: str) -> DS:
             )
 
         domain = parts[1]
-
         # This code will need changing if we want to support multiple domain
         # names on one HS
         return cls(localpart=parts[0], domain=domain)
@@ -279,6 +278,8 @@ def to_string(self) -> str:
     @classmethod
     def is_valid(cls: Type[DS], s: str) -> bool:
         """Parses the input string and attempts to ensure it is valid."""
+        # TODO: this does not reject an empty localpart or an overly-long string.
+        # See https://spec.matrix.org/v1.2/appendices/#identifier-grammar
         try:
             obj = cls.from_string(s)
             # Apply additional validation to the domain. This is only done


Test patch:
diff --git a/tests/http/test_fedclient.py b/tests/http/test_matrixfederationclient.py
similarity index 96%
rename from tests/http/test_fedclient.py
rename to tests/http/test_matrixfederationclient.py
--- a/tests/http/test_fedclient.py
+++ b/tests/http/test_matrixfederationclient.py
@@ -617,3 +617,17 @@ def test_too_big(self):
         self.assertIsInstance(f.value, RequestSendFailed)
 
         self.assertTrue(transport.disconnecting)
+
+    def test_build_auth_headers_rejects_falsey_destinations(self) -> None:
+        with self.assertRaises(ValueError):
+            self.cl.build_auth_headers(None, b"GET", b"https://example.com")
+        with self.assertRaises(ValueError):
+            self.cl.build_auth_headers(b"", b"GET", b"https://example.com")
+        with self.assertRaises(ValueError):
+            self.cl.build_auth_headers(
+                None, b"GET", b"https://example.com", destination_is=b""
+            )
+        with self.assertRaises(ValueError):
+            self.cl.build_auth_headers(
+                b"", b"GET", b"https://example.com", destination_is=b""
+            )
diff --git a/tests/rest/client/test_profile.py b/tests/rest/client/test_profile.py
--- a/tests/rest/client/test_profile.py
+++ b/tests/rest/client/test_profile.py
@@ -13,6 +13,8 @@
 # limitations under the License.
 
 """Tests REST events for /profile paths."""
+import urllib.parse
+from http import HTTPStatus
 from typing import Any, Dict, Optional
 
 from twisted.test.proto_helpers import MemoryReactor
@@ -49,6 +51,12 @@ def test_get_displayname(self) -> None:
         res = self._get_displayname()
         self.assertEqual(res, "owner")
 
+    def test_get_displayname_rejects_bad_username(self) -> None:
+        channel = self.make_request(
+            "GET", f"/profile/{urllib.parse.quote('@alice:')}/displayname"
+        )
+        self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result)
+
     def test_set_displayname(self) -> None:
         channel = self.make_request(
             "PUT",
diff --git a/tests/test_types.py b/tests/test_types.py
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -26,10 +26,21 @@ def test_parse(self):
         self.assertEqual("test", user.domain)
         self.assertEqual(True, self.hs.is_mine(user))
 
-    def test_pase_empty(self):
+    def test_parse_rejects_empty_id(self):
         with self.assertRaises(SynapseError):
             UserID.from_string("")
 
+    def test_parse_rejects_missing_sigil(self):
+        with self.assertRaises(SynapseError):
+            UserID.from_string("alice:example.com")
+
+    def test_parse_rejects_missing_separator(self):
+        with self.assertRaises(SynapseError):
+            UserID.from_string("@alice.example.com")
+
+    def test_validation_rejects_missing_domain(self):
+        self.assertFalse(UserID.is_valid("@alice:"))
+
     def test_build(self):
         user = UserID("5678efgh", "my.domain")