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

Support modifying event content from ThirdPartyRules modules #8535

Merged
merged 5 commits into from
Oct 15, 2020
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions changelog.d/8535.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support modifying event content in `ThirdPartyRules` modules.
6 changes: 6 additions & 0 deletions synapse/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,12 @@ def auth_event_ids(self):
"""
return [e for e, _ in self.auth_events]

def freeze(self):
"""'Freeze' the event dict, so it cannot be modified by accident"""

# this will be a no-op if the event dict is already frozen.
self._dict = freeze(self._dict)


class FrozenEvent(EventBase):
format_version = EventFormatVersions.V1 # All events of this type are V1
Expand Down
19 changes: 13 additions & 6 deletions synapse/events/third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Callable

from typing import Callable, Union

from synapse.events import EventBase
from synapse.events.snapshot import EventContext
Expand Down Expand Up @@ -44,15 +45,20 @@ def __init__(self, hs):

async def check_event_allowed(
self, event: EventBase, context: EventContext
) -> bool:
) -> Union[bool, dict]:
"""Check if a provided event should be allowed in the given context.

The module can return:
* True: the event is allowed.
* False: the event is not allowed, and should be rejected with M_FORBIDDEN.
* a dict: replacement event data.

Args:
event: The event to be checked.
context: The context of the event.

Returns:
True if the event should be allowed, False if not.
The result from the ThirdPartyRules module, as above
"""
if self.third_party_rules is None:
return True
Expand All @@ -63,9 +69,10 @@ async def check_event_allowed(
events = await self.store.get_events(prev_state_ids.values())
state_events = {(ev.type, ev.state_key): ev for ev in events.values()}

# The module can modify the event slightly if it wants, but caution should be
# exercised, and it's likely to go very wrong if applied to events received over
# federation.
# Ensure that the event is frozen, to make sure that the module is not tempted
# to try to modify it. Any attempt to modify it at this point will invalidate
# the hashes and signatures.
event.freeze()

return await self.third_party_rules.check_event_allowed(event, state_events)

Expand Down
66 changes: 3 additions & 63 deletions synapse/handlers/federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,18 +1507,9 @@ async def on_make_join_request(
event, context = await self.event_creation_handler.create_new_client_event(
builder=builder
)
except AuthError as e:
except SynapseError as e:
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm changing this because create_new_client_event will now raise a SynapseError if the third_party_rules check blocks the event.

logger.warning("Failed to create join to %s because %s", room_id, e)
raise e

event_allowed = await self.third_party_event_rules.check_event_allowed(
event, context
)
if not event_allowed:
logger.info("Creation of join %s forbidden by third-party rules", event)
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)
raise

# The remote hasn't signed it yet, obviously. We'll do the full checks
# when we get the event back in `on_send_join_request`
Expand Down Expand Up @@ -1567,15 +1558,6 @@ async def on_send_join_request(self, origin, pdu):

context = await self._handle_new_event(origin, event)

event_allowed = await self.third_party_event_rules.check_event_allowed(
event, context
)
if not event_allowed:
logger.info("Sending of join %s forbidden by third-party rules", event)
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)

logger.debug(
"on_send_join_request: After _handle_new_event: %s, sigs: %s",
event.event_id,
Expand Down Expand Up @@ -1748,15 +1730,6 @@ async def on_make_leave_request(
builder=builder
)

event_allowed = await self.third_party_event_rules.check_event_allowed(
event, context
)
if not event_allowed:
logger.warning("Creation of leave %s forbidden by third-party rules", event)
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)

try:
# The remote hasn't signed it yet, obviously. We'll do the full checks
# when we get the event back in `on_send_leave_request`
Expand Down Expand Up @@ -1789,16 +1762,7 @@ async def on_send_leave_request(self, origin, pdu):

event.internal_metadata.outlier = False

context = await self._handle_new_event(origin, event)

event_allowed = await self.third_party_event_rules.check_event_allowed(
event, context
)
if not event_allowed:
logger.info("Sending of leave %s forbidden by third-party rules", event)
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)
await self._handle_new_event(origin, event)

logger.debug(
"on_send_leave_request: After _handle_new_event: %s, sigs: %s",
Expand Down Expand Up @@ -2694,18 +2658,6 @@ async def exchange_third_party_invite(
builder=builder
)

event_allowed = await self.third_party_event_rules.check_event_allowed(
event, context
)
if not event_allowed:
logger.info(
"Creation of threepid invite %s forbidden by third-party rules",
event,
)
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)

Comment on lines -2697 to -2708
Copy link
Member Author

Choose a reason for hiding this comment

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

afaict, this was redundant, because we later call member_handler.send_membership_event, which then calls handle_new_client_event, which was also doing this check. But in any case, it's doubly redundant now.

event, context = await self.add_display_name_to_third_party_invite(
room_version, event_dict, event, context
)
Expand Down Expand Up @@ -2756,18 +2708,6 @@ async def on_exchange_third_party_invite_request(
event, context = await self.event_creation_handler.create_new_client_event(
builder=builder
)

event_allowed = await self.third_party_event_rules.check_event_allowed(
event, context
)
if not event_allowed:
logger.warning(
"Exchange of threepid invite %s forbidden by third-party rules", event
)
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)

Comment on lines -2759 to -2770
Copy link
Member Author

Choose a reason for hiding this comment

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

(ditto)

event, context = await self.add_display_name_to_third_party_invite(
room_version, event_dict, event, context
)
Expand Down
79 changes: 71 additions & 8 deletions synapse/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,23 @@ async def create_new_client_event(
if requester:
context.app_service = requester.app_service

third_party_result = await self.third_party_event_rules.check_event_allowed(
event, context
)
if not third_party_result:
Copy link
Member

Choose a reason for hiding this comment

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

I suspect it is fine, but returning an empty dict ({}) will result in the event being rejected. I don't think that's even a sane thing to return though so it is probably fine?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I think that's fine.

logger.info(
"Event %s forbidden by third-party rules", event,
)
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)
elif isinstance(third_party_result, dict):
# the third-party rules want to replace the event. We'll need to build a new
# event.
event, context = await self._rebuild_event_after_third_party_rules(
third_party_result, event
)

self.validator.validate_new(event, self.config)

# If this event is an annotation then we check that that the sender
Expand Down Expand Up @@ -881,14 +898,6 @@ async def handle_new_client_event(
else:
room_version = await self.store.get_room_version_id(event.room_id)

event_allowed = await self.third_party_event_rules.check_event_allowed(
Copy link
Member Author

@richvdh richvdh Oct 13, 2020

Choose a reason for hiding this comment

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

this call path is somewhat less obvious than the others, since there are 6 callers of handle_new_client_event. They are:

  • EventCreationHandler._send_dummy_event_for_room
  • EventCreationHandler.create_and_send_nonmember_event
  • RoomCreationHandler._upgrade_room
  • RoomMemberHandler._local_membership_update
  • RoomMemberHandler.send_membership_event, which in turn is called by FederationHandler.exchange_third_party_invite and FederationHandler.on_exchange_third_party_invite_request

All the above create the event by calling EventCreationHandler.create_event, and thence create_new_client_event.

  • RoomMemberMasterHandler._locally_reject_invite

This one is brought in line by #8537.

event, context
)
if not event_allowed:
raise SynapseError(
403, "This event is not allowed in this context", Codes.FORBIDDEN
)

if event.internal_metadata.is_out_of_band_membership():
# the only sort of out-of-band-membership events we expect to see here
# are invite rejections we have generated ourselves.
Expand Down Expand Up @@ -1291,3 +1300,57 @@ def _expire_rooms_to_exclude_from_dummy_event_insertion(self):
room_id,
)
del self._rooms_to_exclude_from_dummy_event_insertion[room_id]

async def _rebuild_event_after_third_party_rules(
self, third_party_result: dict, original_event: EventBase
) -> Tuple[EventBase, EventContext]:
# the third_party_event_rules want to replace the event.
# we do some basic checks, and then return the replacement event and context.

# Construct a new EventBuilder and validate it, which helps with the
# rest of these checks.
try:
builder = self.event_builder_factory.for_room_version(
original_event.room_version, third_party_result
)
self.validator.validate_builder(builder)
except SynapseError as e:
raise Exception(
Copy link
Member

Choose a reason for hiding this comment

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

Each of these is a bare Exception (and not SynapseError) so that they'll bubble and show up in logs and return 500s?

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, exactly.

"Third party rules module created an invalid event: " + e.msg,
)

immutable_fields = [
# changing the room is going to break things: we've already checked that the
# room exists, and are holding a concurrency limiter token for that room.
# Also, we might need to use a different room version.
"room_id",
# changing the type or state key might work, but we'd need to check that the
# calling functions aren't making assumptions about them.
"type",
"state_key",
]

for k in immutable_fields:
if getattr(builder, k, None) != original_event.get(k):
raise Exception(
"Third party rules module created an invalid event: "
"cannot change field " + k
)

# check that the new sender belongs to this HS
if not self.hs.is_mine_id(builder.sender):
raise Exception(
"Third party rules module created an invalid event: "
"invalid sender " + builder.sender
)

# copy over the original internal metadata
for k, v in original_event.internal_metadata.get_dict().items():
setattr(builder.internal_metadata, k, v)

event = await builder.build(prev_event_ids=original_event.prev_event_ids())

# we rebuild the event context, to be on the safe side. If nothing else,
# delta_ids might need an update.
context = await self.state.compute_event_context(event)
return event, context
28 changes: 24 additions & 4 deletions tests/rest/client/test_third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,36 @@ async def check(ev, state):
self.render(request)
self.assertEquals(channel.result["code"], b"403", channel.result)

def test_modify_event(self):
"""Tests that the module can successfully tweak an event before it is persisted.
"""
# first patch the event checker so that it will modify the event
def test_cannot_modify_event(self):
"""cannot accidentally modify an event before it is persisted"""

# first patch the event checker so that it will try to modify the event
async def check(ev: EventBase, state):
ev.content = {"x": "y"}
return True

current_rules_module().check_event_allowed = check

# now send the event
request, channel = self.make_request(
"PUT",
"/_matrix/client/r0/rooms/%s/send/modifyme/1" % self.room_id,
{"x": "x"},
access_token=self.tok,
)
self.render(request)
self.assertEqual(channel.result["code"], b"500", channel.result)

def test_modify_event(self):
"""The module can return a modified version of the event"""
# first patch the event checker so that it will modify the event
async def check(ev: EventBase, state):
d = ev.get_dict()
d["content"] = {"x": "y"}
return d

current_rules_module().check_event_allowed = check

# now send the event
request, channel = self.make_request(
"PUT",
Expand Down