diff --git a/README.md b/README.md index 4e16b665..a3b7eb8a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Phase 2: * [x] Support multiple lists Phase 3: -* [ ] Synapse antispam module +* [x] Synapse antispam module * [ ] Room upgrade handling (both protected+list rooms) * [ ] Support community-defined scopes? (ie: no hardcoded config) * [ ] Riot hooks (independent of mjolnir?) @@ -63,6 +63,48 @@ nano config/development.yaml node lib/index.js ``` +## Synapse Antispam Module + +Using the bot to manage your rooms is great, however if you want to use your ban lists +(or someone else's) on your server to affect all of your users then an antispam module +is needed. Primarily meant to block invites from undesired homeservers/users, Mjolnir's +antispam module is a way to interpret ban lists and apply them to your entire homeserver. + +First, install the module to your Synapse python environment: +``` +pip install -e git+https://github.com/matrix-org/mjolnir.git#egg=mjolnir&subdirectory=synapse_antispam +``` + +*Note*: Where your python environment is depends on your installation method. Visit +[#synapse:matrix.org](https://matrix.to/#/#synapse:matrix.org) if you're not sure. + +Then add the following to your `homeserver.yaml`: +```yaml +spam_checker: + module: mjolnir.AntiSpam + config: + # Prevent servers/users in the ban lists from inviting users on this + # server to rooms. Default true. + block_invites: true + # Flag messages sent by servers/users in the ban lists as spam. Currently + # this means that spammy messages will appear as empty to users. Default + # false. + block_messages: false + # The room IDs of the ban lists to honour. Unlike other parts of Mjolnir, + # this list cannot be room aliases or permalinks. This server is expected + # to already be joined to the room - Mjolnir will not automatically join + # these rooms. + ban_lists: + - "!roomid:example.org" +``` + +Be sure to change the configuration to match your setup. Your server is expected to +already be participating in the ban lists - if it is not, you will need to have a user +on your homeserver join. The antispam module will not join the rooms for you. + +If you change the configuration, you will need to restart Synapse. You'll also need +to restart Synapse to install the plugin. + ## Development TODO. It's a TypeScript project with a linter. diff --git a/synapse_antispam/mjolnir/__init__.py b/synapse_antispam/mjolnir/__init__.py new file mode 100644 index 00000000..76c48524 --- /dev/null +++ b/synapse_antispam/mjolnir/__init__.py @@ -0,0 +1 @@ +from .antispam import AntiSpam diff --git a/synapse_antispam/mjolnir/antispam.py b/synapse_antispam/mjolnir/antispam.py new file mode 100644 index 00000000..ec0234cc --- /dev/null +++ b/synapse_antispam/mjolnir/antispam.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from .list_rule import ALL_RULE_TYPES, RECOMMENDATION_BAN +from .ban_list import BanList +from synapse.types import UserID + +logger = logging.getLogger("synapse.contrib." + __name__) + +class AntiSpam(object): + def __init__(self, config, api): + self.block_invites = config.get("block_invites", True) + self.block_messages = config.get("block_messages", False) + self.list_room_ids = config.get("ban_lists", []) + self.rooms_to_lists = {} # type: Dict[str, BanList] + self.api = api + + # Now we build the ban lists so we can match them + self.build_lists() + + def build_lists(self): + for room_id in self.list_room_ids: + self.build_list(room_id) + + def build_list(self, room_id): + logger.info("Rebuilding ban list for %s" % (room_id)) + self.get_list_for_room(room_id).build() + + def get_list_for_room(self, room_id): + if room_id not in self.rooms_to_lists: + self.rooms_to_lists[room_id] = BanList(api=self.api, room_id=room_id) + return self.rooms_to_lists[room_id] + + def is_user_banned(self, user_id): + for room_id in self.rooms_to_lists: + ban_list = self.rooms_to_lists[room_id] + for rule in ban_list.user_rules: + if rule.matches(user_id): + return rule.action == RECOMMENDATION_BAN + return False + + def is_server_banned(self, server_name): + for room_id in self.rooms_to_lists: + ban_list = self.rooms_to_lists[room_id] + for rule in ban_list.server_rules: + if rule.matches(server_name): + return rule.action == RECOMMENDATION_BAN + return False + + # --- spam checker interface below here --- + + def check_event_for_spam(self, event): + room_id = event.get("room_id", "") + event_type = event.get("type", "") + state_key = event.get("state_key", None) + + # Rebuild the rules if there's an event for our ban lists + if state_key is not None and event_type in ALL_RULE_TYPES and room_id in self.list_room_ids: + logger.info("Received ban list event - updating list") + self.get_list_for_room(room_id).build(with_event=event) + return False # Ban list updates aren't spam + + if not self.block_messages: + return False # not spam (we aren't blocking messages) + + sender = UserID.from_string(event.get("sender", "")) + if self.is_user_banned(sender.to_string()): + return True + if self.is_server_banned(sender.domain): + return True + + return False # not spam (as far as we're concerned) + + def user_may_invite(self, inviter_user_id, invitee_user_id, room_id): + if not self.block_invites: + return True # allowed (we aren't blocking invites) + + sender = UserID.from_string(inviter_user_id) + if self.is_user_banned(sender.to_string()): + return False + if self.is_server_banned(sender.domain): + return False + + return True # allowed (as far as we're concerned) + + def user_may_create_room(self, user_id): + return True # allowed + + def user_may_create_room_alias(self, user_id, room_alias): + return True # allowed + + def user_may_publish_room(self, user_id, room_id): + return True # allowed + + @staticmethod + def parse_config(config): + return config # no parsing needed diff --git a/synapse_antispam/mjolnir/ban_list.py b/synapse_antispam/mjolnir/ban_list.py new file mode 100644 index 00000000..e85b391a --- /dev/null +++ b/synapse_antispam/mjolnir/ban_list.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from .list_rule import ListRule, ALL_RULE_TYPES, USER_RULE_TYPES, SERVER_RULE_TYPES, ROOM_RULE_TYPES +from twisted.internet import defer +from synapse.metrics.background_process_metrics import run_as_background_process + +logger = logging.getLogger("synapse.contrib." + __name__) + +class BanList(object): + def __init__(self, api, room_id): + self.api = api + self.room_id = room_id + self.server_rules = [] + self.user_rules = [] + self.room_rules = [] + self.build() + + def build(with_event=None): + @defer.inlineCallbacks + def run(with_event): + events = yield self.get_relevant_state_events() + if with_event is not None: + events = [*events, with_event] + self.server_rules = [] + self.user_rules = [] + self.room_rules = [] + for event in events: + event_type = event.get("type", "") + state_key = event.get("state_key", "") + content = event.get("content", {}) + if state_key is None: + continue # Some message event got in here? + + # Skip over events which are replaced by with_event. We do this + # to ensure that when we rebuild the list we're using updated rules. + if with_event is not None: + w_event_type = with_event.get("type", "") + w_state_key = with_event.get("state_key", "") + w_event_id = with_event.event_id + event_id = event.event_id + if w_event_type == event_type and w_state_key == state_key and w_event_id != event_id: + continue + + entity = content.get("entity", None) + recommendation = content.get("recommendation", None) + reason = content.get("reason", None) + if entity is None or recommendation is None or reason is None: + continue # invalid event + + logger.info("Adding rule %s/%s with action %s" % (event_type, entity, recommendation)) + rule = ListRule(entity=entity, action=recommendation, reason=reason, kind=event_type) + if event_type in USER_RULE_TYPES: + self.user_rules.append(rule) + elif event_type in ROOM_RULE_TYPES: + self.room_rules.append(rule) + elif event_type in SERVER_RULE_TYPES: + self.server_rules.append(rule) + + run_as_background_process("mjolnir_build_ban_list", run, with_event) + + def get_relevant_state_events(self): + return self.api.get_state_events_in_room(self.room_id, [(t, None) for t in ALL_RULE_TYPES]) diff --git a/synapse_antispam/mjolnir/list_rule.py b/synapse_antispam/mjolnir/list_rule.py new file mode 100644 index 00000000..763e59c1 --- /dev/null +++ b/synapse_antispam/mjolnir/list_rule.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from synapse.util import glob_to_regex + +RECOMMENDATION_BAN = "m.ban" +RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"] + +RULE_USER = "m.room.rule.user" +RULE_ROOM = "m.room.rule.room" +RULE_SERVER = "m.room.rule.server" +USER_RULE_TYPES = [RULE_USER, "org.matrix.mjolnir.rule.user"] +ROOM_RULE_TYPES = [RULE_ROOM, "org.matrix.mjolnir.rule.room"] +SERVER_RULE_TYPES = [RULE_SERVER, "org.matrix.mjolnir.rule.server"] +ALL_RULE_TYPES = [*USER_RULE_TYPES, *ROOM_RULE_TYPES, *SERVER_RULE_TYPES] + +def recommendation_to_stable(recommendation): + if recommendation in RECOMMENDATION_BAN_TYPES: + return RECOMMENDATION_BAN + return None + +def rule_type_to_stable(rule): + if rule in USER_RULE_TYPES: + return RULE_USER + if rule in ROOM_RULE_TYPES: + return RULE_ROOM + if rule in SERVER_RULE_TYPES: + return RULE_SERVER + return None + +class ListRule(object): + def __init__(self, entity, action, reason, kind): + self.entity = entity + self.regex = glob_to_regex(entity) + self.action = recommendation_to_stable(action) + self.reason = reason + self.kind = rule_type_to_stable(kind) + + def matches(self, victim): + return self.regex.match(victim) diff --git a/synapse_antispam/setup.py b/synapse_antispam/setup.py new file mode 100644 index 00000000..53bcf4d3 --- /dev/null +++ b/synapse_antispam/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup, find_packages + +setup( + name="mjolnir", + version="0.0.1", + packages=find_packages(), + description="Mjolnir Antispam", + include_package_data=True, + zip_safe=True, + install_requires=[], +)