Skip to content

Commit 0399642

Browse files
anoadragon453phil-flex
authored andcommitted
Extend spam checker to allow for multiple modules (matrix-org#7435)
1 parent 6cc4771 commit 0399642

File tree

6 files changed

+95
-60
lines changed

6 files changed

+95
-60
lines changed

changelog.d/7435.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow for using more than one spam checker module at once.

docs/sample_config.yaml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1867,10 +1867,17 @@ password_providers:
18671867
# include_content: true
18681868

18691869

1870-
#spam_checker:
1871-
# module: "my_custom_project.SuperSpamChecker"
1872-
# config:
1873-
# example_option: 'things'
1870+
# Spam checkers are third-party modules that can block specific actions
1871+
# of local users, such as creating rooms and registering undesirable
1872+
# usernames, as well as remote users by redacting incoming events.
1873+
#
1874+
spam_checker:
1875+
#- module: "my_custom_project.SuperSpamChecker"
1876+
# config:
1877+
# example_option: 'things'
1878+
#- module: "some_other_project.BadEventStopper"
1879+
# config:
1880+
# example_stop_events_from: ['@bad:example.com']
18741881

18751882

18761883
# Uncomment to allow non-server-admin users to create groups on this server

docs/spam_checker.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,28 @@ class ExampleSpamChecker:
6464
Modify the `spam_checker` section of your `homeserver.yaml` in the following
6565
manner:
6666

67-
`module` should point to the fully qualified Python class that implements your
68-
custom logic, e.g. `my_module.ExampleSpamChecker`.
67+
Create a list entry with the keys `module` and `config`.
6968

70-
`config` is a dictionary that gets passed to the spam checker class.
69+
* `module` should point to the fully qualified Python class that implements your
70+
custom logic, e.g. `my_module.ExampleSpamChecker`.
71+
72+
* `config` is a dictionary that gets passed to the spam checker class.
7173

7274
### Example
7375

7476
This section might look like:
7577

7678
```yaml
7779
spam_checker:
78-
module: my_module.ExampleSpamChecker
79-
config:
80-
# Enable or disable a specific option in ExampleSpamChecker.
81-
my_custom_option: true
80+
- module: my_module.ExampleSpamChecker
81+
config:
82+
# Enable or disable a specific option in ExampleSpamChecker.
83+
my_custom_option: true
8284
```
8385
86+
More spam checkers can be added in tandem by appending more items to the list. An
87+
action is blocked when at least one of the configured spam checkers flags it.
88+
8489
## Examples
8590
8691
The [Mjolnir](https://github.com/matrix-org/mjolnir) project is a full fledged

synapse/config/spam_checker.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
from typing import Any, Dict, List, Tuple
17+
18+
from synapse.config import ConfigError
1619
from synapse.util.module_loader import load_module
1720

1821
from ._base import Config
@@ -22,16 +25,35 @@ class SpamCheckerConfig(Config):
2225
section = "spamchecker"
2326

2427
def read_config(self, config, **kwargs):
25-
self.spam_checker = None
28+
self.spam_checkers = [] # type: List[Tuple[Any, Dict]]
29+
30+
spam_checkers = config.get("spam_checker") or []
31+
if isinstance(spam_checkers, dict):
32+
# The spam_checker config option used to only support one
33+
# spam checker, and thus was simply a dictionary with module
34+
# and config keys. Support this old behaviour by checking
35+
# to see if the option resolves to a dictionary
36+
self.spam_checkers.append(load_module(spam_checkers))
37+
elif isinstance(spam_checkers, list):
38+
for spam_checker in spam_checkers:
39+
if not isinstance(spam_checker, dict):
40+
raise ConfigError("spam_checker syntax is incorrect")
2641

27-
provider = config.get("spam_checker", None)
28-
if provider is not None:
29-
self.spam_checker = load_module(provider)
42+
self.spam_checkers.append(load_module(spam_checker))
43+
else:
44+
raise ConfigError("spam_checker syntax is incorrect")
3045

3146
def generate_config_section(self, **kwargs):
3247
return """\
33-
#spam_checker:
34-
# module: "my_custom_project.SuperSpamChecker"
35-
# config:
36-
# example_option: 'things'
48+
# Spam checkers are third-party modules that can block specific actions
49+
# of local users, such as creating rooms and registering undesirable
50+
# usernames, as well as remote users by redacting incoming events.
51+
#
52+
spam_checker:
53+
#- module: "my_custom_project.SuperSpamChecker"
54+
# config:
55+
# example_option: 'things'
56+
#- module: "some_other_project.BadEventStopper"
57+
# config:
58+
# example_stop_events_from: ['@bad:example.com']
3759
"""

synapse/events/spamcheck.py

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# limitations under the License.
1616

1717
import inspect
18-
from typing import Dict
18+
from typing import Any, Dict, List
1919

2020
from synapse.spam_checker_api import SpamCheckerApi
2121

@@ -26,24 +26,17 @@
2626

2727
class SpamChecker(object):
2828
def __init__(self, hs: "synapse.server.HomeServer"):
29-
self.spam_checker = None
29+
self.spam_checkers = [] # type: List[Any]
3030

31-
module = None
32-
config = None
33-
try:
34-
module, config = hs.config.spam_checker
35-
except Exception:
36-
pass
37-
38-
if module is not None:
31+
for module, config in hs.config.spam_checkers:
3932
# Older spam checkers don't accept the `api` argument, so we
4033
# try and detect support.
4134
spam_args = inspect.getfullargspec(module)
4235
if "api" in spam_args.args:
4336
api = SpamCheckerApi(hs)
44-
self.spam_checker = module(config=config, api=api)
37+
self.spam_checkers.append(module(config=config, api=api))
4538
else:
46-
self.spam_checker = module(config=config)
39+
self.spam_checkers.append(module(config=config))
4740

4841
def check_event_for_spam(self, event: "synapse.events.EventBase") -> bool:
4942
"""Checks if a given event is considered "spammy" by this server.
@@ -58,10 +51,11 @@ def check_event_for_spam(self, event: "synapse.events.EventBase") -> bool:
5851
Returns:
5952
True if the event is spammy.
6053
"""
61-
if self.spam_checker is None:
62-
return False
54+
for spam_checker in self.spam_checkers:
55+
if spam_checker.check_event_for_spam(event):
56+
return True
6357

64-
return self.spam_checker.check_event_for_spam(event)
58+
return False
6559

6660
def user_may_invite(
6761
self, inviter_userid: str, invitee_userid: str, room_id: str
@@ -78,12 +72,14 @@ def user_may_invite(
7872
Returns:
7973
True if the user may send an invite, otherwise False
8074
"""
81-
if self.spam_checker is None:
82-
return True
75+
for spam_checker in self.spam_checkers:
76+
if (
77+
spam_checker.user_may_invite(inviter_userid, invitee_userid, room_id)
78+
is False
79+
):
80+
return False
8381

84-
return self.spam_checker.user_may_invite(
85-
inviter_userid, invitee_userid, room_id
86-
)
82+
return True
8783

8884
def user_may_create_room(self, userid: str) -> bool:
8985
"""Checks if a given user may create a room
@@ -96,10 +92,11 @@ def user_may_create_room(self, userid: str) -> bool:
9692
Returns:
9793
True if the user may create a room, otherwise False
9894
"""
99-
if self.spam_checker is None:
100-
return True
95+
for spam_checker in self.spam_checkers:
96+
if spam_checker.user_may_create_room(userid) is False:
97+
return False
10198

102-
return self.spam_checker.user_may_create_room(userid)
99+
return True
103100

104101
def user_may_create_room_alias(self, userid: str, room_alias: str) -> bool:
105102
"""Checks if a given user may create a room alias
@@ -113,10 +110,11 @@ def user_may_create_room_alias(self, userid: str, room_alias: str) -> bool:
113110
Returns:
114111
True if the user may create a room alias, otherwise False
115112
"""
116-
if self.spam_checker is None:
117-
return True
113+
for spam_checker in self.spam_checkers:
114+
if spam_checker.user_may_create_room_alias(userid, room_alias) is False:
115+
return False
118116

119-
return self.spam_checker.user_may_create_room_alias(userid, room_alias)
117+
return True
120118

121119
def user_may_publish_room(self, userid: str, room_id: str) -> bool:
122120
"""Checks if a given user may publish a room to the directory
@@ -130,10 +128,11 @@ def user_may_publish_room(self, userid: str, room_id: str) -> bool:
130128
Returns:
131129
True if the user may publish the room, otherwise False
132130
"""
133-
if self.spam_checker is None:
134-
return True
131+
for spam_checker in self.spam_checkers:
132+
if spam_checker.user_may_publish_room(userid, room_id) is False:
133+
return False
135134

136-
return self.spam_checker.user_may_publish_room(userid, room_id)
135+
return True
137136

138137
def check_username_for_spam(self, user_profile: Dict[str, str]) -> bool:
139138
"""Checks if a user ID or display name are considered "spammy" by this server.
@@ -150,13 +149,14 @@ def check_username_for_spam(self, user_profile: Dict[str, str]) -> bool:
150149
Returns:
151150
True if the user is spammy.
152151
"""
153-
if self.spam_checker is None:
154-
return False
155-
156-
# For backwards compatibility, if the method does not exist on the spam checker, fallback to not interfering.
157-
checker = getattr(self.spam_checker, "check_username_for_spam", None)
158-
if not checker:
159-
return False
160-
# Make a copy of the user profile object to ensure the spam checker
161-
# cannot modify it.
162-
return checker(user_profile.copy())
152+
for spam_checker in self.spam_checkers:
153+
# For backwards compatibility, only run if the method exists on the
154+
# spam checker
155+
checker = getattr(spam_checker, "check_username_for_spam", None)
156+
if checker:
157+
# Make a copy of the user profile object to ensure the spam checker
158+
# cannot modify it.
159+
if checker(user_profile.copy()):
160+
return True
161+
162+
return False

tests/handlers/test_user_directory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def check_username_for_spam(self, user_profile):
185185
# Allow all users.
186186
return False
187187

188-
spam_checker.spam_checker = AllowAll()
188+
spam_checker.spam_checkers = [AllowAll()]
189189

190190
# The results do not change:
191191
# We get one search result when searching for user2 by user1.
@@ -198,7 +198,7 @@ def check_username_for_spam(self, user_profile):
198198
# All users are spammy.
199199
return True
200200

201-
spam_checker.spam_checker = BlockAll()
201+
spam_checker.spam_checkers = [BlockAll()]
202202

203203
# User1 now gets no search results for any of the other users.
204204
s = self.get_success(self.handler.search_users(u1, "user2", 10))

0 commit comments

Comments
 (0)