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 7 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']
"""
100 changes: 54 additions & 46 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,25 @@

class SpamChecker(object):
def __init__(self, hs: "synapse.server.HomeServer"):
self.spam_checker = None

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

if module is not None:
# 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)
else:
self.spam_checker = module(config=config)
self.spam_checkers = [] # type: List[Any]

for spam_checker in hs.config.spam_checkers:
module = None
config = None
try:
module, config = spam_checker
except Exception:
pass
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved

if module is not None:
# 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_checkers.append(module(config=config, api=api))
else:
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 +59,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 +80,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 +100,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 +118,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 +136,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 +157,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