Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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/19547.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Unfreeze event dict after check_event_allowed module callbacks. Frozen dicts can cause issues with Pydantic validation among other things. Contributed by @c-cal.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Currently, the PR description just says "Related to #18117" but I think this fixes the issue. Is that the case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well it depends of how you look at it: we are fixing the problem for the module callback here, but we are not fixing the fact that a frozen event can be problematic in synapse code globally.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hmmm, I see. I was jumping the gun since this is the only place we call event.freeze().

But we still have the USE_FROZEN_DICTS stuff which appears to do the same thing. At-least in this case, it's a self-inflicted configuration option and happens all the time, not just when a Synapse module is configured and some check_event_allowed callbacks are configured.

It's ironic that this config options says that it "prevents bugs" but introduces a whole new category of potential bugs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to add your own handle here as well

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Perhaps better 🤷 :

Suggested change
Unfreeze event dict after check_event_allowed module callbacks. Frozen dicts can cause issues with Pydantic validation among other things. Contributed by @c-cal.
Unfreeze event dict after module callbacks have run. Frozen dicts can cause issues with Pydantic validation and other type comparisons. Contributed by @c-cal.

Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from synapse.storage.roommember import ProfileInfo
from synapse.types import Requester, StateMap
from synapse.util.async_helpers import delay_cancellation, maybe_awaitable
from synapse.util.frozenutils import unfreeze

if TYPE_CHECKING:
from synapse.server import HomeServer
Expand Down Expand Up @@ -288,6 +289,9 @@ async def check_event_allowed(
# the hashes and signatures.
event.freeze()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should also update the freeze() docstring to callout the specific use cases that it's meant for and that you should remember to unfreeze before going back to Synapse code, etc


allow = True
event_dict = None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
event_dict = None
replacement_event_dict = None


for callback in self._check_event_allowed_callbacks:
try:
res, replacement_data = await delay_cancellation(
Expand All @@ -313,11 +317,18 @@ async def check_event_allowed(
# Return if the event shouldn't be allowed or if the module came up with a
# replacement dict for the event.
if res is False:
return res, None
allow = False
break
elif isinstance(replacement_data, dict):
return True, replacement_data
event_dict = replacement_data
break

# Unfreeze the event dict to avoid potential issues with frozen dicts further
# down the code. Pydantic for example is not happy with frozen dicts.
# cf https://github.com/element-hq/synapse/issues/18117
event._dict = unfreeze(event._dict)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Initial implementation was adding an unfreeze method to EventBase. I changed that since we really don't want this to be called inside the modules.

Copy link
Copy Markdown
Contributor

@MadLittleMods MadLittleMods Mar 17, 2026

Choose a reason for hiding this comment

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

Perhaps we should create new freeze_event(...) and unfreeze_event(...) functions in this file that handles this logic. That way the pair can better be associated together as event._dict is a pretty arbitrary detail.

The location in this file vs being event.freeze() will also prevent people from accidentally mis-using it elsewhere (leaking into other parts of the code that it shouldn't).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Even one step further, we could use a context manager pattern for this with freeze_event(event) as frozen_event: ... so we never forget to unfreeze

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should probably do this in a try/finally block so that if we encounter any error, we still unfreeze things.

Would be good to have a test for this (module that throws an error)


return True, None
return allow, event_dict
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As an aside, I wonder if we have to be careful about someone passing back a frozen replacement_event_dict 🤔

Not something we necessarily have to concern ourselves with this PR but may be another problem. Perhaps we just need to tighten up the docstring details on what we expect passed back.

And hopefully, this doesn't naturally happen from someone taking the event, making a copy, mutating it, and passing it back.


async def on_create_room(
self, requester: Requester, config: dict, is_requester_admin: bool
Expand Down
81 changes: 81 additions & 0 deletions tests/module_api/test_third_party_event_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 The Matrix.org Foundation C.I.C.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
#
from twisted.internet.testing import MemoryReactor

from synapse.module_api import EventBase, StateMap, UserID
from synapse.rest import admin, login, room, room_upgrade_rest_servlet
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock

from tests.server import FakeChannel
from tests.unittest import HomeserverTestCase


class ThirdPartyEventRulesTestCase(HomeserverTestCase):
servlets = [
room.register_servlets,
admin.register_servlets,
login.register_servlets,
room_upgrade_rest_servlet.register_servlets,
]

def prepare(
self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer
) -> None:
self._module_api = homeserver.get_module_api()
self.user_id = self.register_user("user", "password")
self.token = self.login("user", "password")

def create_room(self, content: JsonDict) -> FakeChannel:
channel = self.make_request(
"POST",
"/_matrix/client/r0/createRoom",
content,
access_token=self.token,
)

return channel

def test_check_event_allowed_with_mentions(self) -> None:
"""Test that the check_event_allowed callback works with a message containing mentions."""

async def check_event_allowed(
event: EventBase,
state_events: StateMap,
) -> tuple[bool, dict | None]:
return True, None

self._module_api.register_third_party_rules_callbacks(
check_event_allowed=check_event_allowed
)

channel = self.create_room({})

self.assertEqual(channel.code, 200)

room_id = channel.json_body["room_id"]

event_id = self.create_and_send_event(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👍 Tested that this test fails when we comment out the unfreeze(...) fix

room_id,
UserID.from_string(self.user_id),
content={
"body": "test",
"msgtype": "m.text",
"m.mentions": {},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should call out that this is the secret sauce that tests xxx and is important because xxx

},
)

self.assertNotEquals(event_id, None)
5 changes: 4 additions & 1 deletion tests/unittest.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,7 @@ def create_and_send_event(
user: UserID,
soft_failed: bool = False,
prev_event_ids: list[str] | None = None,
content: dict[str, Any] | None = None,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

From the new usage, it looks like this was done in order to be able to define m.mentions

) -> str:
"""
Create and send an event.
Expand All @@ -878,6 +879,7 @@ def create_and_send_event(
soft_failed: Whether to create a soft failed event or not
prev_event_ids: Explicitly set the prev events,
or if None just use the default
content: Event content to send, or if None use a default

Returns:
The new event's ID.
Expand All @@ -892,7 +894,8 @@ def create_and_send_event(
"type": EventTypes.Message,
"room_id": room_id,
"sender": user.to_string(),
"content": {"body": secrets.token_hex(), "msgtype": "m.text"},
"content": content
or {"body": secrets.token_hex(), "msgtype": "m.text"},
},
prev_event_ids=prev_event_ids,
)
Expand Down
Loading