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

Commit 28199e9

Browse files
authored
Uniformize spam-checker API, part 2: check_event_for_spam (#12808)
Signed-off-by: David Teller <[email protected]>
1 parent 4cc4229 commit 28199e9

File tree

10 files changed

+129
-31
lines changed

10 files changed

+129
-31
lines changed

changelog.d/12808.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Update to `check_event_for_spam`. Deprecate the current callback signature, replace it with a new signature that is both less ambiguous (replacing booleans with explicit allow/block) and more powerful (ability to return explicit error codes).

docs/modules/spam_checker_callbacks.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,29 @@ The available spam checker callbacks are:
1111
### `check_event_for_spam`
1212

1313
_First introduced in Synapse v1.37.0_
14+
_Signature extended to support Allow and Code in Synapse v1.60.0_
15+
_Boolean and string return value types deprecated in Synapse v1.60.0_
1416

1517
```python
16-
async def check_event_for_spam(event: "synapse.events.EventBase") -> Union[bool, str]
18+
async def check_event_for_spam(event: "synapse.module_api.EventBase") -> Union["synapse.module_api.ALLOW", "synapse.module_api.error.Codes", str, bool]
1719
```
1820

19-
Called when receiving an event from a client or via federation. The callback must return
20-
either:
21-
- an error message string, to indicate the event must be rejected because of spam and
22-
give a rejection reason to forward to clients;
23-
- the boolean `True`, to indicate that the event is spammy, but not provide further details; or
24-
- the booelan `False`, to indicate that the event is not considered spammy.
21+
Called when receiving an event from a client or via federation. The callback must return either:
22+
- `synapse.module_api.ALLOW`, to allow the operation. Other callbacks
23+
may still decide to reject it.
24+
- `synapse.api.Codes` to reject the operation with an error code. In case
25+
of doubt, `synapse.api.error.Codes.FORBIDDEN` is a good error code.
26+
- (deprecated) a `str` to reject the operation and specify an error message. Note that clients
27+
typically will not localize the error message to the user's preferred locale.
28+
- (deprecated) on `False`, behave as `ALLOW`. Deprecated as confusing, as some
29+
callbacks in expect `True` to allow and others `True` to reject.
30+
- (deprecated) on `True`, behave as `synapse.api.error.Codes.FORBIDDEN`. Deprecated as confusing, as
31+
some callbacks in expect `True` to allow and others `True` to reject.
2532

2633
If multiple modules implement this callback, they will be considered in order. If a
27-
callback returns `False`, Synapse falls through to the next one. The value of the first
28-
callback that does not return `False` will be used. If this happens, Synapse will not call
29-
any of the subsequent implementations of this callback.
34+
callback returns `synapse.module_api.ALLOW`, Synapse falls through to the next one. The value of the
35+
first callback that does not return `synapse.module_api.ALLOW` will be used. If this happens, Synapse
36+
will not call any of the subsequent implementations of this callback.
3037

3138
### `user_may_join_room`
3239

docs/upgrade.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,36 @@ has queries that can be used to check a database for this problem in advance.
177177
178178
</details>
179179
180+
## SpamChecker API's `check_event_for_spam` has a new signature.
180181

182+
The previous signature has been deprecated.
183+
184+
Whereas `check_event_for_spam` callbacks used to return `Union[str, bool]`, they should now return `Union["synapse.module_api.Allow", "synapse.module_api.errors.Codes"]`.
185+
186+
This is part of an ongoing refactoring of the SpamChecker API to make it less ambiguous and more powerful.
187+
188+
If your module implements `check_event_for_spam` as follows:
189+
190+
```python
191+
async def check_event_for_spam(event):
192+
if ...:
193+
# Event is spam
194+
return True
195+
# Event is not spam
196+
return False
197+
```
198+
199+
you should rewrite it as follows:
200+
201+
```python
202+
async def check_event_for_spam(event):
203+
if ...:
204+
# Event is spam, mark it as forbidden (you may use some more precise error
205+
# code if it is useful).
206+
return synapse.module_api.errors.Codes.FORBIDDEN
207+
# Event is not spam, mark it as `ALLOW`.
208+
return synapse.module_api.ALLOW
209+
```
181210
182211
# Upgrading to v1.59.0
183212

synapse/api/errors.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,7 @@ class UnrecognizedRequestError(SynapseError):
270270
"""An error indicating we don't understand the request you're trying to make"""
271271

272272
def __init__(
273-
self,
274-
msg: str = "Unrecognized request",
275-
errcode: str = Codes.UNRECOGNIZED,
273+
self, msg: str = "Unrecognized request", errcode: str = Codes.UNRECOGNIZED
276274
):
277275
super().__init__(400, msg, errcode)
278276

synapse/events/spamcheck.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
Union,
2828
)
2929

30+
from synapse.api.errors import Codes
3031
from synapse.rest.media.v1._base import FileInfo
3132
from synapse.rest.media.v1.media_storage import ReadableFileWrapper
32-
from synapse.spam_checker_api import RegistrationBehaviour
33+
from synapse.spam_checker_api import Allow, Decision, RegistrationBehaviour
3334
from synapse.types import RoomAlias, UserProfile
3435
from synapse.util.async_helpers import delay_cancellation, maybe_awaitable
3536
from synapse.util.metrics import Measure
@@ -40,9 +41,19 @@
4041

4142
logger = logging.getLogger(__name__)
4243

44+
4345
CHECK_EVENT_FOR_SPAM_CALLBACK = Callable[
4446
["synapse.events.EventBase"],
45-
Awaitable[Union[bool, str]],
47+
Awaitable[
48+
Union[
49+
Allow,
50+
Codes,
51+
# Deprecated
52+
bool,
53+
# Deprecated
54+
str,
55+
]
56+
],
4657
]
4758
SHOULD_DROP_FEDERATED_EVENT_CALLBACK = Callable[
4859
["synapse.events.EventBase"],
@@ -259,7 +270,7 @@ def register_callbacks(
259270

260271
async def check_event_for_spam(
261272
self, event: "synapse.events.EventBase"
262-
) -> Union[bool, str]:
273+
) -> Union[Decision, str]:
263274
"""Checks if a given event is considered "spammy" by this server.
264275
265276
If the server considers an event spammy, then it will be rejected if
@@ -270,18 +281,36 @@ async def check_event_for_spam(
270281
event: the event to be checked
271282
272283
Returns:
273-
True or a string if the event is spammy. If a string is returned it
274-
will be used as the error message returned to the user.
284+
- on `ALLOW`, the event is considered good (non-spammy) and should
285+
be let through. Other spamcheck filters may still reject it.
286+
- on `Code`, the event is considered spammy and is rejected with a specific
287+
error message/code.
288+
- on `str`, the event is considered spammy and the string is used as error
289+
message. This usage is generally discouraged as it doesn't support
290+
internationalization.
275291
"""
276292
for callback in self._check_event_for_spam_callbacks:
277293
with Measure(
278294
self.clock, "{}.{}".format(callback.__module__, callback.__qualname__)
279295
):
280-
res: Union[bool, str] = await delay_cancellation(callback(event))
281-
if res:
282-
return res
283-
284-
return False
296+
res: Union[Decision, str, bool] = await delay_cancellation(
297+
callback(event)
298+
)
299+
if res is False or res is Allow.ALLOW:
300+
# This spam-checker accepts the event.
301+
# Other spam-checkers may reject it, though.
302+
continue
303+
elif res is True:
304+
# This spam-checker rejects the event with deprecated
305+
# return value `True`
306+
return Codes.FORBIDDEN
307+
else:
308+
# This spam-checker rejects the event either with a `str`
309+
# or with a `Codes`. In either case, we stop here.
310+
return res
311+
312+
# No spam-checker has rejected the event, let it pass.
313+
return Allow.ALLOW
285314

286315
async def should_drop_federated_event(
287316
self, event: "synapse.events.EventBase"

synapse/federation/federation_base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import logging
1616
from typing import TYPE_CHECKING
1717

18+
import synapse
1819
from synapse.api.constants import MAX_DEPTH, EventContentFields, EventTypes, Membership
1920
from synapse.api.errors import Codes, SynapseError
2021
from synapse.api.room_versions import EventFormatVersions, RoomVersion
@@ -98,9 +99,9 @@ async def _check_sigs_and_hash(
9899
)
99100
return redacted_event
100101

101-
result = await self.spam_checker.check_event_for_spam(pdu)
102+
spam_check = await self.spam_checker.check_event_for_spam(pdu)
102103

103-
if result:
104+
if spam_check is not synapse.spam_checker_api.Allow.ALLOW:
104105
logger.warning("Event contains spam, soft-failing %s", pdu.event_id)
105106
# we redact (to save disk space) as well as soft-failing (to stop
106107
# using the event in prev_events).

synapse/handlers/message.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from twisted.internet.interfaces import IDelayedCall
2525

26+
import synapse
2627
from synapse import event_auth
2728
from synapse.api.constants import (
2829
EventContentFields,
@@ -885,11 +886,11 @@ async def create_and_send_nonmember_event(
885886
event.sender,
886887
)
887888

888-
spam_error = await self.spam_checker.check_event_for_spam(event)
889-
if spam_error:
890-
if not isinstance(spam_error, str):
891-
spam_error = "Spam is not permitted here"
892-
raise SynapseError(403, spam_error, Codes.FORBIDDEN)
889+
spam_check = await self.spam_checker.check_event_for_spam(event)
890+
if spam_check is not synapse.spam_checker_api.Allow.ALLOW:
891+
raise SynapseError(
892+
403, "This message had been rejected as probable spam", spam_check
893+
)
893894

894895
ev = await self.handle_new_client_event(
895896
requester=requester,

synapse/module_api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from twisted.internet import defer
3636
from twisted.web.resource import Resource
3737

38+
from synapse import spam_checker_api
3839
from synapse.api.errors import SynapseError
3940
from synapse.events import EventBase
4041
from synapse.events.presence_router import (
@@ -140,13 +141,17 @@
140141

141142
PRESENCE_ALL_USERS = PresenceRouter.ALL_USERS
142143

144+
ALLOW = spam_checker_api.Allow.ALLOW
145+
# Singleton value used to mark a message as permitted.
146+
143147
__all__ = [
144148
"errors",
145149
"make_deferred_yieldable",
146150
"parse_json_object_from_request",
147151
"respond_with_html",
148152
"run_in_background",
149153
"cached",
154+
"Allow",
150155
"UserID",
151156
"DatabasePool",
152157
"LoggingTransaction",

synapse/module_api/errors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Exception types which are exposed as part of the stable module API"""
1616

1717
from synapse.api.errors import (
18+
Codes,
1819
InvalidClientCredentialsError,
1920
RedirectException,
2021
SynapseError,
@@ -24,6 +25,7 @@
2425
from synapse.storage.push_rule import RuleNotFoundException
2526

2627
__all__ = [
28+
"Codes",
2729
"InvalidClientCredentialsError",
2830
"RedirectException",
2931
"SynapseError",

synapse/spam_checker_api/__init__.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,38 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
from enum import Enum
15+
from typing import Union
16+
17+
from synapse.api.errors import Codes
1518

1619

1720
class RegistrationBehaviour(Enum):
1821
"""
19-
Enum to define whether a registration request should allowed, denied, or shadow-banned.
22+
Enum to define whether a registration request should be allowed, denied, or shadow-banned.
2023
"""
2124

2225
ALLOW = "allow"
2326
SHADOW_BAN = "shadow_ban"
2427
DENY = "deny"
28+
29+
30+
# We define the following singleton enum rather than a string to be able to
31+
# write `Union[Allow, ..., str]` in some of the callbacks for the spam-checker
32+
# API, where the `str` is required to maintain backwards compatibility with
33+
# previous versions of the API.
34+
class Allow(Enum):
35+
"""
36+
Singleton to allow events to pass through in SpamChecker APIs.
37+
"""
38+
39+
ALLOW = "allow"
40+
41+
42+
Decision = Union[Allow, Codes]
43+
"""
44+
Union to define whether a request should be allowed or rejected.
45+
46+
To accept a request, return `ALLOW`.
47+
48+
To reject a request without any specific information, use `Codes.FORBIDDEN`.
49+
"""

0 commit comments

Comments
 (0)