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

Extend spam checker to allow for multiple modules #7435

Merged
merged 8 commits into from
May 8, 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/7435.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow for using more than one spam checker module at once.
15 changes: 11 additions & 4 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1760,10 +1760,17 @@ password_providers:
# include_content: true


#spam_checker:
# module: "my_custom_project.SuperSpamChecker"
# config:
# example_option: 'things'
# Spam checkers are third-party modules that can block specific actions
# of local users, such as creating rooms and registering undesirable
# usernames, as well as remote users by redacting incoming events.
#
spam_checker:
#- module: "my_custom_project.SuperSpamChecker"
# config:
# example_option: 'things'
#- module: "some_other_project.BadEventStopper"
# config:
# example_stop_events_from: ['@bad:example.com']


# Uncomment to allow non-server-admin users to create groups on this server
Expand Down
19 changes: 12 additions & 7 deletions docs/spam_checker.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,28 @@ class ExampleSpamChecker:
Modify the `spam_checker` section of your `homeserver.yaml` in the following
manner:

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

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

* `config` is a dictionary that gets passed to the spam checker class.

### Example

This section might look like:

```yaml
spam_checker:
module: my_module.ExampleSpamChecker
config:
# Enable or disable a specific option in ExampleSpamChecker.
my_custom_option: true
- module: my_module.ExampleSpamChecker
config:
# Enable or disable a specific option in ExampleSpamChecker.
my_custom_option: true
```

More spam checkers can be added in tandem by appending more items to the list. An
action is blocked when at least one of the configured spam checkers flags it.

## Examples

The [Mjolnir](https://github.com/matrix-org/mjolnir) project is a full fledged
Expand Down
38 changes: 30 additions & 8 deletions synapse/config/spam_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Any, Dict, List, Tuple

from synapse.config import ConfigError
from synapse.util.module_loader import load_module

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

def read_config(self, config, **kwargs):
self.spam_checker = None
self.spam_checkers = [] # type: List[Tuple[Any, Dict]]

spam_checkers = config.get("spam_checker") or []
if isinstance(spam_checkers, dict):
# The spam_checker config option used to only support one
# spam checker, and thus was simply a dictionary with module
# and config keys. Support this old behaviour by checking
# to see if the option resolves to a dictionary
self.spam_checkers.append(load_module(spam_checkers))
elif isinstance(spam_checkers, list):
for spam_checker in spam_checkers:
if not isinstance(spam_checker, dict):
raise ConfigError("spam_checker syntax is incorrect")

provider = config.get("spam_checker", None)
if provider is not None:
self.spam_checker = load_module(provider)
self.spam_checkers.append(load_module(spam_checker))
else:
raise ConfigError("spam_checker syntax is incorrect")

def generate_config_section(self, **kwargs):
return """\
#spam_checker:
# module: "my_custom_project.SuperSpamChecker"
# config:
# example_option: 'things'
# Spam checkers are third-party modules that can block specific actions
# of local users, such as creating rooms and registering undesirable
# usernames, as well as remote users by redacting incoming events.
#
spam_checker:
#- module: "my_custom_project.SuperSpamChecker"
# config:
# example_option: 'things'
#- module: "some_other_project.BadEventStopper"
# config:
# example_stop_events_from: ['@bad:example.com']
"""
78 changes: 39 additions & 39 deletions synapse/events/spamcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# limitations under the License.

import inspect
from typing import Dict
from typing import Any, Dict, List

from synapse.spam_checker_api import SpamCheckerApi

Expand All @@ -26,24 +26,17 @@

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

module = None
config = None
try:
module, config = hs.config.spam_checker
except Exception:
pass

if module is not None:
for module, config in hs.config.spam_checkers:
# Older spam checkers don't accept the `api` argument, so we
# try and detect support.
spam_args = inspect.getfullargspec(module)
if "api" in spam_args.args:
api = SpamCheckerApi(hs)
self.spam_checker = module(config=config, api=api)
self.spam_checkers.append(module(config=config, api=api))
else:
self.spam_checker = module(config=config)
self.spam_checkers.append(module(config=config))

def check_event_for_spam(self, event: "synapse.events.EventBase") -> bool:
"""Checks if a given event is considered "spammy" by this server.
Expand All @@ -58,10 +51,11 @@ def check_event_for_spam(self, event: "synapse.events.EventBase") -> bool:
Returns:
True if the event is spammy.
"""
if self.spam_checker is None:
return False
for spam_checker in self.spam_checkers:
if spam_checker.check_event_for_spam(event):
return True

return self.spam_checker.check_event_for_spam(event)
return False

def user_may_invite(
self, inviter_userid: str, invitee_userid: str, room_id: str
Expand All @@ -78,12 +72,14 @@ def user_may_invite(
Returns:
True if the user may send an invite, otherwise False
"""
if self.spam_checker is None:
return True
for spam_checker in self.spam_checkers:
if (
spam_checker.user_may_invite(inviter_userid, invitee_userid, room_id)
is False
):
clokep marked this conversation as resolved.
Show resolved Hide resolved
return False

return self.spam_checker.user_may_invite(
inviter_userid, invitee_userid, room_id
)
return True

def user_may_create_room(self, userid: str) -> bool:
"""Checks if a given user may create a room
Expand All @@ -96,10 +92,11 @@ def user_may_create_room(self, userid: str) -> bool:
Returns:
True if the user may create a room, otherwise False
"""
if self.spam_checker is None:
return True
for spam_checker in self.spam_checkers:
if spam_checker.user_may_create_room(userid) is False:
return False

return self.spam_checker.user_may_create_room(userid)
return True

def user_may_create_room_alias(self, userid: str, room_alias: str) -> bool:
"""Checks if a given user may create a room alias
Expand All @@ -113,10 +110,11 @@ def user_may_create_room_alias(self, userid: str, room_alias: str) -> bool:
Returns:
True if the user may create a room alias, otherwise False
"""
if self.spam_checker is None:
return True
for spam_checker in self.spam_checkers:
if spam_checker.user_may_create_room_alias(userid, room_alias) is False:
return False

return self.spam_checker.user_may_create_room_alias(userid, room_alias)
return True

def user_may_publish_room(self, userid: str, room_id: str) -> bool:
"""Checks if a given user may publish a room to the directory
Expand All @@ -130,10 +128,11 @@ def user_may_publish_room(self, userid: str, room_id: str) -> bool:
Returns:
True if the user may publish the room, otherwise False
"""
if self.spam_checker is None:
return True
for spam_checker in self.spam_checkers:
if spam_checker.user_may_publish_room(userid, room_id) is False:
return False

return self.spam_checker.user_may_publish_room(userid, room_id)
return True

def check_username_for_spam(self, user_profile: Dict[str, str]) -> bool:
"""Checks if a user ID or display name are considered "spammy" by this server.
Expand All @@ -150,13 +149,14 @@ def check_username_for_spam(self, user_profile: Dict[str, str]) -> bool:
Returns:
True if the user is spammy.
"""
if self.spam_checker is None:
return False

# For backwards compatibility, if the method does not exist on the spam checker, fallback to not interfering.
checker = getattr(self.spam_checker, "check_username_for_spam", None)
if not checker:
return False
# Make a copy of the user profile object to ensure the spam checker
# cannot modify it.
return checker(user_profile.copy())
for spam_checker in self.spam_checkers:
# For backwards compatibility, only run if the method exists on the
# spam checker
checker = getattr(spam_checker, "check_username_for_spam", None)
if checker:
# Make a copy of the user profile object to ensure the spam checker
# cannot modify it.
if checker(user_profile.copy()):
return True

return False
4 changes: 2 additions & 2 deletions tests/handlers/test_user_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def check_username_for_spam(self, user_profile):
# Allow all users.
return False

spam_checker.spam_checker = AllowAll()
spam_checker.spam_checkers = [AllowAll()]

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

spam_checker.spam_checker = BlockAll()
spam_checker.spam_checkers = [BlockAll()]

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