Repository: matrix-org/synapse

Issue:
Synapse is overly accepting of content in the `unsigned` object in events received over federation
Synapse makes use of various properties within the `unsigned` object of events - either internally, or by passing them on to clients. One example is `replaces_state`, which is used to store the event id of the previous event with the same `type` and `state_key`, and is later used to populate the `prev_content` property for events served to clients.

The problem is that homeservers are free to populate `unsigned`, without it affecting the event hashes or signatures; a malicious or buggy homeserver could therefore populate the content with incorrect data.

Taking the example of `replaces_state`, Synapse overwrites this property when receiving an event, but only if there was previously an event with the same `type` and `state_key` in the room state; it is otherwise passed through unchanged. So, a malicious homeserver could confuse remote servers' clients by sending incorrect values of `replaces_state` over federation.

---

The specification is not clear on how unspecified properties within `unsigned` should be handled, but I think they should be stripped off by the receiving homeserver. This will ensure that if, in future, the C-S API spec is extended to specify new properties be added to `unsigned`, there will be no confusion about whether they were added by the local or remote homeserver.

As far as I am aware, the only properties that *should* be allowed in `unsigned` over federation are:
 * `invite_room_state`
 * `knock_room_state`
 * `age` - though see also https://github.com/matrix-org/synapse/issues/8429.

[Aside: in an ideal world, we might have different properties for "things added by the remote homeserver - treat with caution!" vs "things added by the local homeserver - can be trusted". However, that ship has probably sailed for now.] 


Discussion:
Couldn't we add a `"remote"` key under `unsigned` that logs all of the properties received directly(!) over federation in the spec? That way, clients can pick and choose what they trust andsoforth.

I say "directly", as i think that if `remote` keys embedded in forwarded events will lead to disaster, maybe a security issue, and probably a recursion amplification and such.
> Couldn't we add a "remote" key under unsigned

I'm failing to understand you. You're suggesting we move `invite_room_state` and friends to a different key? Well, as noted, yes we could, but that would be an annoying breaking change.
giving this a security label, as I think it could be abused by malicious HS impls.
@richvdh Can you clarify why you added this back to the list for discussion by the team? Suspect it has to do with matrix-org/matrix-doc#3522 merging.

Is the anticipated resolution:

- [ ] Stop Synapse from returning `replaces_state` (or any other unsigned property) over federation.
- [ ] Strip all `unsigned` values arriving over federation, except for an allowlist of `invite_room_state`, `knock_room_state`, and `age`
I don't think it ever got discussed by the team, so it's not really adding it *back* to the list.

Given its security implications, I think that is worthwhile considering how we prioritise this.

> Is the anticipated resolution:
> 
>  * Stop Synapse from returning replaces_state (or any other unsigned property) over federation.
>  * Strip all unsigned values arriving over federation, except for an allowlist of invite_room_state, knock_room_state, and age

Just the latter is necessary, I think. (And I'd only allow `invite_room_state` and `knock_room_state` for event types that are meant to have it).

Resolving patch:
diff --git a/synapse/federation/federation_base.py b/synapse/federation/federation_base.py
--- a/synapse/federation/federation_base.py
+++ b/synapse/federation/federation_base.py
@@ -230,6 +230,10 @@ def event_from_pdu_json(pdu_json: JsonDict, room_version: RoomVersion) -> EventB
     # origin, etc etc)
     assert_params_in_dict(pdu_json, ("type", "depth"))
 
+    # Strip any unauthorized values from "unsigned" if they exist
+    if "unsigned" in pdu_json:
+        _strip_unsigned_values(pdu_json)
+
     depth = pdu_json["depth"]
     if not isinstance(depth, int):
         raise SynapseError(400, "Depth %r not an intger" % (depth,), Codes.BAD_JSON)
@@ -245,3 +249,24 @@ def event_from_pdu_json(pdu_json: JsonDict, room_version: RoomVersion) -> EventB
 
     event = make_event_from_dict(pdu_json, room_version)
     return event
+
+
+def _strip_unsigned_values(pdu_dict: JsonDict) -> None:
+    """
+    Strip any unsigned values unless specifically allowed, as defined by the whitelist.
+
+    pdu: the json dict to strip values from. Note that the dict is mutated by this
+    function
+    """
+    unsigned = pdu_dict["unsigned"]
+
+    if not isinstance(unsigned, dict):
+        pdu_dict["unsigned"] = {}
+
+    if pdu_dict["type"] == "m.room.member":
+        whitelist = ["knock_room_state", "invite_room_state", "age"]
+    else:
+        whitelist = ["age"]
+
+    filtered_unsigned = {k: v for k, v in unsigned.items() if k in whitelist}
+    pdu_dict["unsigned"] = filtered_unsigned


Test patch:
diff --git a/tests/test_federation.py b/tests/test_federation.py
--- a/tests/test_federation.py
+++ b/tests/test_federation.py
@@ -17,7 +17,9 @@
 from twisted.internet.defer import succeed
 
 from synapse.api.errors import FederationError
+from synapse.api.room_versions import RoomVersions
 from synapse.events import make_event_from_dict
+from synapse.federation.federation_base import event_from_pdu_json
 from synapse.logging.context import LoggingContext
 from synapse.types import UserID, create_requester
 from synapse.util import Clock
@@ -276,3 +278,73 @@ def test_cross_signing_keys_retry(self):
             "ed25519:" + remote_self_signing_key in self_signing_key["keys"].keys(),
         )
         self.assertTrue(remote_self_signing_key in self_signing_key["keys"].values())
+
+
+class StripUnsignedFromEventsTestCase(unittest.TestCase):
+    def test_strip_unauthorized_unsigned_values(self):
+        event1 = {
+            "sender": "@baduser:test.serv",
+            "state_key": "@baduser:test.serv",
+            "event_id": "$event1:test.serv",
+            "depth": 1000,
+            "origin_server_ts": 1,
+            "type": "m.room.member",
+            "origin": "test.servx",
+            "content": {"membership": "join"},
+            "auth_events": [],
+            "unsigned": {"malicious garbage": "hackz", "more warez": "more hackz"},
+        }
+        filtered_event = event_from_pdu_json(event1, RoomVersions.V1)
+        # Make sure unauthorized fields are stripped from unsigned
+        self.assertNotIn("more warez", filtered_event.unsigned)
+
+    def test_strip_event_maintains_allowed_fields(self):
+        event2 = {
+            "sender": "@baduser:test.serv",
+            "state_key": "@baduser:test.serv",
+            "event_id": "$event2:test.serv",
+            "depth": 1000,
+            "origin_server_ts": 1,
+            "type": "m.room.member",
+            "origin": "test.servx",
+            "auth_events": [],
+            "content": {"membership": "join"},
+            "unsigned": {
+                "malicious garbage": "hackz",
+                "more warez": "more hackz",
+                "age": 14,
+                "invite_room_state": [],
+            },
+        }
+
+        filtered_event2 = event_from_pdu_json(event2, RoomVersions.V1)
+        self.assertIn("age", filtered_event2.unsigned)
+        self.assertEqual(14, filtered_event2.unsigned["age"])
+        self.assertNotIn("more warez", filtered_event2.unsigned)
+        # Invite_room_state is allowed in events of type m.room.member
+        self.assertIn("invite_room_state", filtered_event2.unsigned)
+        self.assertEqual([], filtered_event2.unsigned["invite_room_state"])
+
+    def test_strip_event_removes_fields_based_on_event_type(self):
+        event3 = {
+            "sender": "@baduser:test.serv",
+            "state_key": "@baduser:test.serv",
+            "event_id": "$event3:test.serv",
+            "depth": 1000,
+            "origin_server_ts": 1,
+            "type": "m.room.power_levels",
+            "origin": "test.servx",
+            "content": {},
+            "auth_events": [],
+            "unsigned": {
+                "malicious garbage": "hackz",
+                "more warez": "more hackz",
+                "age": 14,
+                "invite_room_state": [],
+            },
+        }
+        filtered_event3 = event_from_pdu_json(event3, RoomVersions.V1)
+        self.assertIn("age", filtered_event3.unsigned)
+        # Invite_room_state field is only permitted in event type m.room.member
+        self.assertNotIn("invite_room_state", filtered_event3.unsigned)
+        self.assertNotIn("more warez", filtered_event3.unsigned)