diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index 1d0de60b2d..e36461486b 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -286,6 +286,10 @@ class AccountDataTypes:
IGNORED_USER_LIST: Final = "m.ignored_user_list"
TAG: Final = "m.tag"
PUSH_RULES: Final = "m.push_rules"
+ # MSC4155: Invite filtering
+ MSC4155_INVITE_PERMISSION_CONFIG: Final = (
+ "org.matrix.msc4155.invite_permission_config"
+ )
class HistoryVisibility:
diff --git a/synapse/api/errors.py b/synapse/api/errors.py
index edd2073384..3eb533f7d5 100644
--- a/synapse/api/errors.py
+++ b/synapse/api/errors.py
@@ -137,6 +137,9 @@ class Codes(str, Enum):
PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE"
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
+ # Part of MSC4155
+ INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED"
+
class CodeMessageException(RuntimeError):
"""An exception with integer code, a message string attributes and optional headers.
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 2dc75a778e..259b2c70cb 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -566,3 +566,6 @@ class ExperimentalConfig(Config):
"msc4263_limit_key_queries_to_users_who_share_rooms",
False,
)
+
+ # MSC4155: Invite filtering
+ self.msc4155_enabled: bool = experimental.get("msc4155_enabled", False)
diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py
index b1640e3246..a6de3e824d 100644
--- a/synapse/handlers/federation.py
+++ b/synapse/handlers/federation.py
@@ -78,6 +78,7 @@ from synapse.replication.http.federation import (
ReplicationStoreRoomOnOutlierMembershipRestServlet,
)
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
+from synapse.storage.invite_rule import InviteRule
from synapse.types import JsonDict, StrCollection, get_domain_from_id
from synapse.types.state import StateFilter
from synapse.util.async_helpers import Linearizer
@@ -1089,6 +1090,20 @@ class FederationHandler:
if event.state_key == self._server_notices_mxid:
raise SynapseError(HTTPStatus.FORBIDDEN, "Cannot invite this user")
+ # check the invitee's configuration and apply rules
+ invite_config = await self.store.get_invite_config_for_user(event.state_key)
+ rule = invite_config.get_invite_rule(event.sender)
+ if rule == InviteRule.BLOCK:
+ logger.info(
+ f"Automatically rejecting invite from {event.sender} due to the invite filtering rules of {event.state_key}"
+ )
+ raise SynapseError(
+ 403,
+ "You are not permitted to invite this user.",
+ errcode=Codes.INVITE_BLOCKED,
+ )
+ # InviteRule.IGNORE is handled at the sync layer
+
# We retrieve the room member handler here as to not cause a cyclic dependency
member_handler = self.hs.get_room_member_handler()
# We don't rate limit based on room ID, as that should be done by
diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py
index b0e750c9c7..24ee5e89a6 100644
--- a/synapse/handlers/room_member.py
+++ b/synapse/handlers/room_member.py
@@ -53,6 +53,7 @@ from synapse.metrics import event_processing_positions
from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.replication.http.push import ReplicationCopyPusherRestServlet
from synapse.storage.databases.main.state_deltas import StateDelta
+from synapse.storage.invite_rule import InviteRule
from synapse.types import (
JsonDict,
Requester,
@@ -915,6 +916,21 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
additional_fields=block_invite_result[1],
)
+ # check the invitee's configuration and apply rules. Admins on the server can bypass.
+ if not is_requester_admin:
+ invite_config = await self.store.get_invite_config_for_user(target_id)
+ rule = invite_config.get_invite_rule(requester.user.to_string())
+ if rule == InviteRule.BLOCK:
+ logger.info(
+ f"Automatically rejecting invite from {target_id} due to the the invite filtering rules of {requester.user}"
+ )
+ raise SynapseError(
+ 403,
+ "You are not permitted to invite this user.",
+ errcode=Codes.INVITE_BLOCKED,
+ )
+ # InviteRule.IGNORE is handled at the sync layer.
+
# An empty prev_events list is allowed as long as the auth_event_ids are present
if prev_event_ids is not None:
return await self._local_membership_update(
diff --git a/synapse/handlers/sliding_sync/room_lists.py b/synapse/handlers/sliding_sync/room_lists.py
index 6d1ac91605..13e69f18a0 100644
--- a/synapse/handlers/sliding_sync/room_lists.py
+++ b/synapse/handlers/sliding_sync/room_lists.py
@@ -49,6 +49,7 @@ from synapse.storage.databases.main.state import (
Sentinel as StateSentinel,
)
from synapse.storage.databases.main.stream import CurrentStateDeltaMembership
+from synapse.storage.invite_rule import InviteRule
from synapse.storage.roommember import (
RoomsForUser,
RoomsForUserSlidingSync,
@@ -278,6 +279,7 @@ class SlidingSyncRoomLists:
# Remove invites from ignored users
ignored_users = await self.store.ignored_users(user_id)
+ invite_config = await self.store.get_invite_config_for_user(user_id)
if ignored_users:
# FIXME: It would be nice to avoid this copy but since
# `get_sliding_sync_rooms_for_user_from_membership_snapshots` is cached, it
@@ -292,7 +294,14 @@ class SlidingSyncRoomLists:
room_for_user_sliding_sync = room_membership_for_user_map[room_id]
if (
room_for_user_sliding_sync.membership == Membership.INVITE
- and room_for_user_sliding_sync.sender in ignored_users
+ and room_for_user_sliding_sync.sender
+ and (
+ room_for_user_sliding_sync.sender in ignored_users
+ or invite_config.get_invite_rule(
+ room_for_user_sliding_sync.sender
+ )
+ == InviteRule.IGNORE
+ )
):
room_membership_for_user_map.pop(room_id, None)
diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py
index 350c3fa09a..c6f2c38d8d 100644
--- a/synapse/handlers/sync.py
+++ b/synapse/handlers/sync.py
@@ -66,6 +66,7 @@ from synapse.logging.opentracing import (
from synapse.storage.databases.main.event_push_actions import RoomNotifCounts
from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary
from synapse.storage.databases.main.stream import PaginateFunction
+from synapse.storage.invite_rule import InviteRule
from synapse.storage.roommember import MemberSummary
from synapse.types import (
DeviceListUpdates,
@@ -2549,6 +2550,7 @@ class SyncHandler:
room_entries: List[RoomSyncResultBuilder] = []
invited: List[InvitedSyncResult] = []
knocked: List[KnockedSyncResult] = []
+ invite_config = await self.store.get_invite_config_for_user(user_id)
for room_id, events in mem_change_events_by_room_id.items():
# The body of this loop will add this room to at least one of the five lists
# above. Things get messy if you've e.g. joined, left, joined then left the
@@ -2631,7 +2633,11 @@ class SyncHandler:
# Only bother if we're still currently invited
should_invite = last_non_join.membership == Membership.INVITE
if should_invite:
- if last_non_join.sender not in ignored_users:
+ if (
+ last_non_join.sender not in ignored_users
+ and invite_config.get_invite_rule(last_non_join.sender)
+ != InviteRule.IGNORE
+ ):
invite_room_sync = InvitedSyncResult(room_id, invite=last_non_join)
if invite_room_sync:
invited.append(invite_room_sync)
@@ -2786,6 +2792,7 @@ class SyncHandler:
membership_list=Membership.LIST,
excluded_rooms=sync_result_builder.excluded_room_ids,
)
+ invite_config = await self.store.get_invite_config_for_user(user_id)
room_entries = []
invited = []
@@ -2811,6 +2818,8 @@ class SyncHandler:
elif event.membership == Membership.INVITE:
if event.sender in ignored_users:
continue
+ if invite_config.get_invite_rule(event.sender) == InviteRule.IGNORE:
+ continue
invite = await self.store.get_event(event.event_id)
invited.append(InvitedSyncResult(room_id=event.room_id, invite=invite))
elif event.membership == Membership.KNOCK:
diff --git a/synapse/push/bulk_push_rule_evaluator.py b/synapse/push/bulk_push_rule_evaluator.py
index 8c106f9649..8249d5e84f 100644
--- a/synapse/push/bulk_push_rule_evaluator.py
+++ b/synapse/push/bulk_push_rule_evaluator.py
@@ -52,6 +52,7 @@ from synapse.events.snapshot import EventContext
from synapse.logging.context import make_deferred_yieldable, run_in_background
from synapse.state import POWER_KEY
from synapse.storage.databases.main.roommember import EventIdMembership
+from synapse.storage.invite_rule import InviteRule
from synapse.storage.roommember import ProfileInfo
from synapse.synapse_rust.push import FilteredPushRules, PushRuleEvaluator
from synapse.types import JsonValue
@@ -191,9 +192,17 @@ class BulkPushRuleEvaluator:
# if this event is an invite event, we may need to run rules for the user
# who's been invited, otherwise they won't get told they've been invited
- if event.type == EventTypes.Member and event.membership == Membership.INVITE:
+ if (
+ event.is_state()
+ and event.type == EventTypes.Member
+ and event.membership == Membership.INVITE
+ ):
invited = event.state_key
- if invited and self.hs.is_mine_id(invited) and invited not in local_users:
+ invite_config = await self.store.get_invite_config_for_user(invited)
+ if invite_config.get_invite_rule(event.sender) != InviteRule.ALLOW:
+ # Invite was blocked or ignored, never notify.
+ return {}
+ if self.hs.is_mine_id(invited) and invited not in local_users:
local_users.append(invited)
if not local_users:
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index 266a0b835b..f58f11e5cc 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -174,6 +174,8 @@ class VersionsRestServlet(RestServlet):
"org.matrix.simplified_msc3575": msc3575_enabled,
# Arbitrary key-value profile fields.
"uk.tcpip.msc4133": self.config.experimental.msc4133_enabled,
+ # MSC4155: Invite filtering
+ "org.matrix.msc4155": self.config.experimental.msc4155_enabled,
},
},
)
diff --git a/synapse/storage/databases/main/account_data.py b/synapse/storage/databases/main/account_data.py
index e583c182ba..d26de1ad16 100644
--- a/synapse/storage/databases/main/account_data.py
+++ b/synapse/storage/databases/main/account_data.py
@@ -43,6 +43,7 @@ from synapse.storage.database import (
)
from synapse.storage.databases.main.cache import CacheInvalidationWorkerStore
from synapse.storage.databases.main.push_rule import PushRulesWorkerStore
+from synapse.storage.invite_rule import InviteRulesConfig
from synapse.storage.util.id_generators import MultiWriterIdGenerator
from synapse.types import JsonDict, JsonMapping
from synapse.util import json_encoder
@@ -102,6 +103,8 @@ class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore)
self._delete_account_data_for_deactivated_users,
)
+ self._msc4155_enabled = hs.config.experimental.msc4155_enabled
+
def get_max_account_data_stream_id(self) -> int:
"""Get the current max stream ID for account data stream
@@ -557,6 +560,23 @@ class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore)
)
)
+ async def get_invite_config_for_user(self, user_id: str) -> InviteRulesConfig:
+ """
+ Get the invite configuration for the current user.
+
+ Args:
+ user_id:
+ """
+
+ if not self._msc4155_enabled:
+ # This equates to allowing all invites, as if the setting was off.
+ return InviteRulesConfig(None)
+
+ data = await self.get_global_account_data_by_type_for_user(
+ user_id, AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG
+ )
+ return InviteRulesConfig(data)
+
def process_replication_rows(
self,
stream_name: str,
diff --git a/synapse/storage/databases/main/user_directory.py b/synapse/storage/databases/main/user_directory.py
index 2b867cdb6e..31a8ce6666 100644
--- a/synapse/storage/databases/main/user_directory.py
+++ b/synapse/storage/databases/main/user_directory.py
@@ -43,6 +43,7 @@ try:
USE_ICU = True
except ModuleNotFoundError:
+ # except ModuleNotFoundError:
USE_ICU = False
from synapse.api.errors import StoreError
diff --git a/synapse/storage/invite_rule.py b/synapse/storage/invite_rule.py
new file mode 100644
index 0000000000..b9d9d1eb62
--- /dev/null
+++ b/synapse/storage/invite_rule.py
@@ -0,0 +1,110 @@
+import logging
+from enum import Enum
+from typing import Optional, Pattern
+
+from matrix_common.regex import glob_to_regex
+
+from synapse.types import JsonMapping, UserID
+
+logger = logging.getLogger(__name__)
+
+
+class InviteRule(Enum):
+ """Enum to define the action taken when an invite matches a rule."""
+
+ ALLOW = "allow"
+ BLOCK = "block"
+ IGNORE = "ignore"
+
+
+class InviteRulesConfig:
+ """Class to determine if a given user permits an invite from another user, and the action to take."""
+
+ def __init__(self, account_data: Optional[JsonMapping]):
+ self.allowed_users: list[Pattern[str]] = []
+ self.ignored_users: list[Pattern[str]] = []
+ self.blocked_users: list[Pattern[str]] = []
+
+ self.allowed_servers: list[Pattern[str]] = []
+ self.ignored_servers: list[Pattern[str]] = []
+ self.blocked_servers: list[Pattern[str]] = []
+
+ def process_field(
+ values: Optional[list[str]],
+ ruleset: list[Pattern[str]],
+ rule: InviteRule,
+ ) -> None:
+ if isinstance(values, list):
+ for value in values:
+ if isinstance(value, str) and len(value) > 0:
+ # User IDs cannot exceed 255 bytes. Don't process large, potentially
+ # expensive glob patterns.
+ if len(value) > 255:
+ logger.debug(
+ "Ignoring invite config glob pattern that is >255 bytes: {value}"
+ )
+ continue
+
+ try:
+ ruleset.append(glob_to_regex(value))
+ except Exception as e:
+ # If for whatever reason we can't process this, just ignore it.
+ logger.debug(
+ f"Could not process '{value}' field of invite rule config, ignoring: {e}"
+ )
+
+ if account_data:
+ process_field(
+ account_data.get("allowed_users"), self.allowed_users, InviteRule.ALLOW
+ )
+ process_field(
+ account_data.get("ignored_users"), self.ignored_users, InviteRule.IGNORE
+ )
+ process_field(
+ account_data.get("blocked_users"), self.blocked_users, InviteRule.BLOCK
+ )
+ process_field(
+ account_data.get("allowed_servers"),
+ self.allowed_servers,
+ InviteRule.ALLOW,
+ )
+ process_field(
+ account_data.get("ignored_servers"),
+ self.ignored_servers,
+ InviteRule.IGNORE,
+ )
+ process_field(
+ account_data.get("blocked_servers"),
+ self.blocked_servers,
+ InviteRule.BLOCK,
+ )
+
+ def get_invite_rule(self, user_id: str) -> InviteRule:
+ """Get the invite rule that matches this user. Will return InviteRule.ALLOW if no rules match
+
+ Args:
+ user_id: The user ID of the inviting user.
+
+ """
+ user = UserID.from_string(user_id)
+ # The order here is important. We always process user rules before server rules
+ # and we always process in the order of Allow, Ignore, Block.
+ for patterns, rule in [
+ (self.allowed_users, InviteRule.ALLOW),
+ (self.ignored_users, InviteRule.IGNORE),
+ (self.blocked_users, InviteRule.BLOCK),
+ ]:
+ for regex in patterns:
+ if regex.match(user_id):
+ return rule
+
+ for patterns, rule in [
+ (self.allowed_servers, InviteRule.ALLOW),
+ (self.ignored_servers, InviteRule.IGNORE),
+ (self.blocked_servers, InviteRule.BLOCK),
+ ]:
+ for regex in patterns:
+ if regex.match(user.domain):
+ return rule
+
+ return InviteRule.ALLOW
|