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

Strip unauthorized fields from unsigned object in events received over federation #11530

Merged
merged 21 commits into from
Jan 6, 2022
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/11530.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix a long-standing issue which could cause Synapse to incorrectly accept data in the unsigned field of events
received over federation.
33 changes: 33 additions & 0 deletions synapse/federation/federation_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ def event_from_pdu_json(
# 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 and pdu_json["unsigned"] != {}:
H-Shay marked this conversation as resolved.
Show resolved Hide resolved
pdu_json = _strip_unsigned_values(pdu_json)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's worth noting that, because _strip_unsigned_values modifies the actual dict that pdu_json refers to, this is the same as:

Suggested change
pdu_json = _strip_unsigned_values(pdu_json)
_strip_unsigned_values(pdu_json)

Indeed, I think it's confusing to have _strip_unsigned_values return the dict at all, since it gives the incorrect impression that it returns a different dict, rather than modifying the original.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, thank you for pointing that out!


depth = pdu_json["depth"]
if not isinstance(depth, int):
raise SynapseError(400, "Depth %r not an intger" % (depth,), Codes.BAD_JSON)
Expand All @@ -255,3 +259,32 @@ def event_from_pdu_json(
event.internal_metadata.outlier = outlier

return event


def _strip_unsigned_values(pdu_dict: JsonDict) -> JsonDict:
"""
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"] = {}
return pdu_dict

if pdu_dict["type"] == "m.room.member":
whitelist = ["knock_room_state", "invite_room_state", "age"]
else:
whitelist = ["age"]

filtered_unsigned = {}

for k, v in unsigned.items():
if k in whitelist:
filtered_unsigned[k] = v
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'd find this easier to read as:

Suggested change
filtered_unsigned = {}
for k, v in unsigned.items():
if k in whitelist:
filtered_unsigned[k] = v
filtered_unsigned = {k: v for k, v in unsigned.items() if k in whitelist}

That might just be me though, this way is fine too.


pdu_dict["unsigned"] = filtered_unsigned

return pdu_dict
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove this: It's no longer used, and I think that returning a value makes it look like we might be returning something different from the input.

Suggested change
return pdu_dict

likewise, s/return pdu_dict/return/ above, and you'll need to change the annotation to -> None.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right makes sense, also thank you for the quick and enlightening review!

90 changes: 90 additions & 0 deletions tests/test_federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -276,3 +278,91 @@ 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(MessageAcceptTests):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MessageAcceptTests includes a load of its own tests, so by deriving your test class from it in this way, we end up with another copy of those tests, and so do them twice (see https://github.com/matrix-org/synapse/runs/4612144055?check_suite_focus=true#step:8:2560).

I think your tests will be fine without prev_state and prev_events, so I think you can remove them and the calls to get_latest_event_ids_in_room. Then you can just inherit from UnitTest, rather than having to do all the complicated setup in MessageAcceptTests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out, it's helpful to know.

def test_strip_unauthorized_unsigned_values(self):
most_recent = self.get_success(
self.homeserver.get_datastore().get_latest_event_ids_in_room(self.room_id)
)[0]
event1 = {
"room_id": self.room_id,
"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": [],
"prev_state": [(most_recent, {})],
"prev_events": [(most_recent, {})],
"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):
most_recent = self.get_success(
self.homeserver.get_datastore().get_latest_event_ids_in_room(self.room_id)
)[0]
event2 = {
"room_id": self.room_id,
"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": [],
"prev_state": [(most_recent, {})],
"prev_events": [(most_recent, {})],
"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):
most_recent = self.get_success(
self.homeserver.get_datastore().get_latest_event_ids_in_room(self.room_id)
)[0]
event3 = {
"room_id": self.room_id,
"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": [],
"prev_state": [(most_recent, {})],
"prev_events": [(most_recent, {})],
"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)