Skip to content

Commit b7d4841

Browse files
turt2livedevonh
andauthored
Policy server part 1: Actually call the policy server (#18387)
Roughly reviewable commit-by-commit. This is the first part of adding policy server support to Synapse. Other parts (unordered), which may or may not be bundled into fewer PRs, include: * Implementation of a bulk API * Supporting a moderation server config (the `fallback_*` options of https://github.com/element-hq/policyserv_spam_checker ) * Adding an "early event hook" for appservices to receive federation transactions *before* events are processed formally * Performance and stability improvements ### Pull Request Checklist <!-- Please read https://element-hq.github.io/synapse/latest/development/contributing_guide.html before submitting your pull request --> * [x] Pull request is based on the develop branch * [x] Pull request includes a [changelog file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog). The entry should: - Be a short description of your change which makes sense to users. "Fixed a bug that prevented receiving messages from other servers." instead of "Moved X method from `EventStore` to `EventWorkerStore`.". - Use markdown where necessary, mostly for `code blocks`. - End with either a period (.) or an exclamation mark (!). - Start with a capital letter. - Feel free to credit yourself, by adding a sentence "Contributed by @github_username." or "Contributed by [Your Name]." to the end of the entry. * [x] [Code style](https://element-hq.github.io/synapse/latest/code_style.html) is correct (run the [linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters)) --------- Co-authored-by: turt2live <[email protected]> Co-authored-by: Devon Hudson <[email protected]>
1 parent 553e124 commit b7d4841

File tree

9 files changed

+469
-1
lines changed

9 files changed

+469
-1
lines changed

changelog.d/18387.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for calling Policy Servers ([MSC4284](https://github.com/matrix-org/matrix-spec-proposals/pull/4284)) to mark events as spam.

synapse/federation/federation_base.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from synapse.events import EventBase, make_event_from_dict
3131
from synapse.events.utils import prune_event, validate_canonicaljson
3232
from synapse.federation.units import filter_pdus_for_valid_depth
33+
from synapse.handlers.room_policy import RoomPolicyHandler
3334
from synapse.http.servlet import assert_params_in_dict
3435
from synapse.logging.opentracing import log_kv, trace
3536
from synapse.types import JsonDict, get_domain_from_id
@@ -64,6 +65,24 @@ def __init__(self, hs: "HomeServer"):
6465
self._clock = hs.get_clock()
6566
self._storage_controllers = hs.get_storage_controllers()
6667

68+
# We need to define this lazily otherwise we get a cyclic dependency.
69+
# self._policy_handler = hs.get_room_policy_handler()
70+
self._policy_handler: Optional[RoomPolicyHandler] = None
71+
72+
def _lazily_get_policy_handler(self) -> RoomPolicyHandler:
73+
"""Lazily get the room policy handler.
74+
75+
This is required to avoid an import cycle: RoomPolicyHandler requires a
76+
FederationClient, which requires a FederationBase, which requires a
77+
RoomPolicyHandler.
78+
79+
Returns:
80+
RoomPolicyHandler: The room policy handler.
81+
"""
82+
if self._policy_handler is None:
83+
self._policy_handler = self.hs.get_room_policy_handler()
84+
return self._policy_handler
85+
6786
@trace
6887
async def _check_sigs_and_hash(
6988
self,
@@ -80,6 +99,10 @@ async def _check_sigs_and_hash(
8099
Also runs the event through the spam checker; if it fails, redacts the event
81100
and flags it as soft-failed.
82101
102+
Also checks that the event is allowed by the policy server, if the room uses
103+
a policy server. If the event is not allowed, the event is flagged as
104+
soft-failed but not redacted.
105+
83106
Args:
84107
room_version: The room version of the PDU
85108
pdu: the event to be checked
@@ -145,6 +168,17 @@ async def _check_sigs_and_hash(
145168
)
146169
return redacted_event
147170

171+
policy_allowed = await self._lazily_get_policy_handler().is_event_allowed(pdu)
172+
if not policy_allowed:
173+
logger.warning(
174+
"Event not allowed by policy server, soft-failing %s", pdu.event_id
175+
)
176+
pdu.internal_metadata.soft_failed = True
177+
# Note: we don't redact the event so admins can inspect the event after the
178+
# fact. Other processes may redact the event, but that won't be applied to
179+
# the database copy of the event until the server's config requires it.
180+
return pdu
181+
148182
spam_check = await self._spam_checker_module_callbacks.check_event_for_spam(pdu)
149183

150184
if spam_check != self._spam_checker_module_callbacks.NOT_SPAM:

synapse/federation/federation_client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
from synapse.http.types import QueryParams
7676
from synapse.logging.opentracing import SynapseTags, log_kv, set_tag, tag_args, trace
7777
from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id
78+
from synapse.types.handlers.policy_server import RECOMMENDATION_OK, RECOMMENDATION_SPAM
7879
from synapse.util.async_helpers import concurrently_execute
7980
from synapse.util.caches.expiringcache import ExpiringCache
8081
from synapse.util.retryutils import NotRetryingDestination
@@ -421,6 +422,62 @@ async def _record_failure_callback(
421422

422423
return None
423424

425+
@trace
426+
@tag_args
427+
async def get_pdu_policy_recommendation(
428+
self, destination: str, pdu: EventBase, timeout: Optional[int] = None
429+
) -> str:
430+
"""Requests that the destination server (typically a policy server)
431+
check the event and return its recommendation on how to handle the
432+
event.
433+
434+
If the policy server could not be contacted or the policy server
435+
returned an unknown recommendation, this returns an OK recommendation.
436+
This type fixing behaviour is done because the typical caller will be
437+
in a critical call path and would generally interpret a `None` or similar
438+
response as "weird value; don't care; move on without taking action". We
439+
just frontload that logic here.
440+
441+
442+
Args:
443+
destination: The remote homeserver to ask (a policy server)
444+
pdu: The event to check
445+
timeout: How long to try (in ms) the destination for before
446+
giving up. None indicates no timeout.
447+
448+
Returns:
449+
The policy recommendation, or RECOMMENDATION_OK if the policy server was
450+
uncontactable or returned an unknown recommendation.
451+
"""
452+
453+
logger.debug(
454+
"get_pdu_policy_recommendation for event_id=%s from %s",
455+
pdu.event_id,
456+
destination,
457+
)
458+
459+
try:
460+
res = await self.transport_layer.get_policy_recommendation_for_pdu(
461+
destination, pdu, timeout=timeout
462+
)
463+
recommendation = res.get("recommendation")
464+
if not isinstance(recommendation, str):
465+
raise InvalidResponseError("recommendation is not a string")
466+
if recommendation not in (RECOMMENDATION_OK, RECOMMENDATION_SPAM):
467+
logger.warning(
468+
"get_pdu_policy_recommendation: unknown recommendation: %s",
469+
recommendation,
470+
)
471+
return RECOMMENDATION_OK
472+
return recommendation
473+
except Exception as e:
474+
logger.warning(
475+
"get_pdu_policy_recommendation: server %s responded with error, assuming OK recommendation: %s",
476+
destination,
477+
e,
478+
)
479+
return RECOMMENDATION_OK
480+
424481
@trace
425482
@tag_args
426483
async def get_pdu(

synapse/federation/transport/client.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,33 @@ async def get_event(
143143
destination, path=path, timeout=timeout, try_trailing_slash_on_400=True
144144
)
145145

146+
async def get_policy_recommendation_for_pdu(
147+
self, destination: str, event: EventBase, timeout: Optional[int] = None
148+
) -> JsonDict:
149+
"""Requests the policy recommendation for the given pdu from the given policy server.
150+
151+
Args:
152+
destination: The host name of the remote homeserver checking the event.
153+
event: The event to check.
154+
timeout: How long to try (in ms) the destination for before giving up.
155+
None indicates no timeout.
156+
157+
Returns:
158+
The full recommendation object from the remote server.
159+
"""
160+
logger.debug(
161+
"get_policy_recommendation_for_pdu dest=%s, event_id=%s",
162+
destination,
163+
event.event_id,
164+
)
165+
return await self.client.post_json(
166+
destination=destination,
167+
path=f"/_matrix/policy/unstable/org.matrix.msc4284/event/{event.event_id}/check",
168+
data=event.get_pdu_json(),
169+
ignore_backoff=True,
170+
timeout=timeout,
171+
)
172+
146173
async def backfill(
147174
self, destination: str, room_id: str, event_tuples: Collection[str], limit: int
148175
) -> Optional[Union[JsonDict, list]]:

synapse/handlers/message.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ def __init__(self, hs: "HomeServer"):
495495
self._instance_name = hs.get_instance_name()
496496
self._notifier = hs.get_notifier()
497497
self._worker_lock_handler = hs.get_worker_locks_handler()
498+
self._policy_handler = hs.get_room_policy_handler()
498499

499500
self.room_prejoin_state_types = self.hs.config.api.room_prejoin_state
500501

@@ -1108,6 +1109,18 @@ async def _create_and_send_nonmember_event_locked(
11081109
event.sender,
11091110
)
11101111

1112+
policy_allowed = await self._policy_handler.is_event_allowed(event)
1113+
if not policy_allowed:
1114+
logger.warning(
1115+
"Event not allowed by policy server, rejecting %s",
1116+
event.event_id,
1117+
)
1118+
raise SynapseError(
1119+
403,
1120+
"This message has been rejected as probable spam",
1121+
Codes.FORBIDDEN,
1122+
)
1123+
11111124
spam_check_result = (
11121125
await self._spam_checker_module_callbacks.check_event_for_spam(
11131126
event
@@ -1119,7 +1132,7 @@ async def _create_and_send_nonmember_event_locked(
11191132
[code, dict] = spam_check_result
11201133
raise SynapseError(
11211134
403,
1122-
"This message had been rejected as probable spam",
1135+
"This message has been rejected as probable spam",
11231136
code,
11241137
dict,
11251138
)

synapse/handlers/room_policy.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright 2016-2021 The Matrix.org Foundation C.I.C.
5+
# Copyright (C) 2023 New Vector, Ltd
6+
#
7+
# This program is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU Affero General Public License as
9+
# published by the Free Software Foundation, either version 3 of the
10+
# License, or (at your option) any later version.
11+
#
12+
# See the GNU Affero General Public License for more details:
13+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
14+
#
15+
#
16+
17+
import logging
18+
from typing import TYPE_CHECKING
19+
20+
from synapse.events import EventBase
21+
from synapse.types.handlers.policy_server import RECOMMENDATION_OK
22+
from synapse.util.stringutils import parse_and_validate_server_name
23+
24+
if TYPE_CHECKING:
25+
from synapse.server import HomeServer
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class RoomPolicyHandler:
31+
def __init__(self, hs: "HomeServer"):
32+
self._hs = hs
33+
self._store = hs.get_datastores().main
34+
self._storage_controllers = hs.get_storage_controllers()
35+
self._event_auth_handler = hs.get_event_auth_handler()
36+
self._federation_client = hs.get_federation_client()
37+
38+
async def is_event_allowed(self, event: EventBase) -> bool:
39+
"""Check if the given event is allowed in the room by the policy server.
40+
41+
Note: This will *always* return True if the room's policy server is Synapse
42+
itself. This is because Synapse can't be a policy server (currently).
43+
44+
If no policy server is configured in the room, this returns True. Similarly, if
45+
the policy server is invalid in any way (not joined, not a server, etc), this
46+
returns True.
47+
48+
If a valid and contactable policy server is configured in the room, this returns
49+
True if that server suggests the event is not spammy, and False otherwise.
50+
51+
Args:
52+
event: The event to check. This should be a fully-formed PDU.
53+
54+
Returns:
55+
bool: True if the event is allowed in the room, False otherwise.
56+
"""
57+
policy_event = await self._storage_controllers.state.get_current_state_event(
58+
event.room_id, "org.matrix.msc4284.policy", ""
59+
)
60+
if not policy_event:
61+
return True # no policy server == default allow
62+
63+
policy_server = policy_event.content.get("via", "")
64+
if policy_server is None or not isinstance(policy_server, str):
65+
return True # no policy server == default allow
66+
67+
if policy_server == self._hs.hostname:
68+
return True # Synapse itself can't be a policy server (currently)
69+
70+
try:
71+
parse_and_validate_server_name(policy_server)
72+
except ValueError:
73+
return True # invalid policy server == default allow
74+
75+
is_in_room = await self._event_auth_handler.is_host_in_room(
76+
event.room_id, policy_server
77+
)
78+
if not is_in_room:
79+
return True # policy server not in room == default allow
80+
81+
# At this point, the server appears valid and is in the room, so ask it to check
82+
# the event.
83+
recommendation = await self._federation_client.get_pdu_policy_recommendation(
84+
policy_server, event
85+
)
86+
if recommendation != RECOMMENDATION_OK:
87+
return False
88+
89+
return True # default allow

synapse/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
RoomMemberMasterHandler,
108108
)
109109
from synapse.handlers.room_member_worker import RoomMemberWorkerHandler
110+
from synapse.handlers.room_policy import RoomPolicyHandler
110111
from synapse.handlers.room_summary import RoomSummaryHandler
111112
from synapse.handlers.search import SearchHandler
112113
from synapse.handlers.send_email import SendEmailHandler
@@ -807,6 +808,10 @@ def get_oidc_handler(self) -> "OidcHandler":
807808

808809
return OidcHandler(self)
809810

811+
@cache_in_self
812+
def get_room_policy_handler(self) -> RoomPolicyHandler:
813+
return RoomPolicyHandler(self)
814+
810815
@cache_in_self
811816
def get_event_client_serializer(self) -> EventClientSerializer:
812817
return EventClientSerializer(self)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
#
14+
15+
RECOMMENDATION_OK = "ok"
16+
RECOMMENDATION_SPAM = "spam"

0 commit comments

Comments
 (0)