Add support for MSC4155 Invite filtering (#18288)
This implements https://github.com/matrix-org/matrix-spec-proposals/pull/4155, which adds support for a new account data type that blocks an invite based on some conditions in the event contents. --------- Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
This commit is contained in:
1
changelog.d/18288.feature
Normal file
1
changelog.d/18288.feature
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Add support for [MSC4155](https://github.com/matrix-org/matrix-spec-proposals/pull/4155) Invite Filtering.
|
||||||
@@ -127,6 +127,8 @@ experimental_features:
|
|||||||
msc3983_appservice_otk_claims: true
|
msc3983_appservice_otk_claims: true
|
||||||
# Proxy key queries to exclusive ASes
|
# Proxy key queries to exclusive ASes
|
||||||
msc3984_appservice_key_query: true
|
msc3984_appservice_key_query: true
|
||||||
|
# Invite filtering
|
||||||
|
msc4155_enabled: true
|
||||||
|
|
||||||
server_notices:
|
server_notices:
|
||||||
system_mxid_localpart: _server
|
system_mxid_localpart: _server
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ test_packages=(
|
|||||||
./tests/msc3902
|
./tests/msc3902
|
||||||
./tests/msc3967
|
./tests/msc3967
|
||||||
./tests/msc4140
|
./tests/msc4140
|
||||||
|
./tests/msc4155
|
||||||
)
|
)
|
||||||
|
|
||||||
# Enable dirty runs, so tests will reuse the same container where possible.
|
# Enable dirty runs, so tests will reuse the same container where possible.
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ class AccountDataTypes:
|
|||||||
IGNORED_USER_LIST: Final = "m.ignored_user_list"
|
IGNORED_USER_LIST: Final = "m.ignored_user_list"
|
||||||
TAG: Final = "m.tag"
|
TAG: Final = "m.tag"
|
||||||
PUSH_RULES: Final = "m.push_rules"
|
PUSH_RULES: Final = "m.push_rules"
|
||||||
|
# MSC4155: Invite filtering
|
||||||
|
MSC4155_INVITE_PERMISSION_CONFIG: Final = (
|
||||||
|
"org.matrix.msc4155.invite_permission_config"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HistoryVisibility:
|
class HistoryVisibility:
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ class Codes(str, Enum):
|
|||||||
PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE"
|
PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE"
|
||||||
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
|
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
|
||||||
|
|
||||||
|
# Part of MSC4155
|
||||||
|
INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED"
|
||||||
|
|
||||||
|
|
||||||
class CodeMessageException(RuntimeError):
|
class CodeMessageException(RuntimeError):
|
||||||
"""An exception with integer code, a message string attributes and optional headers.
|
"""An exception with integer code, a message string attributes and optional headers.
|
||||||
|
|||||||
@@ -566,3 +566,6 @@ class ExperimentalConfig(Config):
|
|||||||
"msc4263_limit_key_queries_to_users_who_share_rooms",
|
"msc4263_limit_key_queries_to_users_who_share_rooms",
|
||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MSC4155: Invite filtering
|
||||||
|
self.msc4155_enabled: bool = experimental.get("msc4155_enabled", False)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ from synapse.replication.http.federation import (
|
|||||||
ReplicationStoreRoomOnOutlierMembershipRestServlet,
|
ReplicationStoreRoomOnOutlierMembershipRestServlet,
|
||||||
)
|
)
|
||||||
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
|
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 import JsonDict, StrCollection, get_domain_from_id
|
||||||
from synapse.types.state import StateFilter
|
from synapse.types.state import StateFilter
|
||||||
from synapse.util.async_helpers import Linearizer
|
from synapse.util.async_helpers import Linearizer
|
||||||
@@ -1089,6 +1090,20 @@ class FederationHandler:
|
|||||||
if event.state_key == self._server_notices_mxid:
|
if event.state_key == self._server_notices_mxid:
|
||||||
raise SynapseError(HTTPStatus.FORBIDDEN, "Cannot invite this user")
|
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
|
# We retrieve the room member handler here as to not cause a cyclic dependency
|
||||||
member_handler = self.hs.get_room_member_handler()
|
member_handler = self.hs.get_room_member_handler()
|
||||||
# We don't rate limit based on room ID, as that should be done by
|
# We don't rate limit based on room ID, as that should be done by
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from synapse.metrics import event_processing_positions
|
|||||||
from synapse.metrics.background_process_metrics import run_as_background_process
|
from synapse.metrics.background_process_metrics import run_as_background_process
|
||||||
from synapse.replication.http.push import ReplicationCopyPusherRestServlet
|
from synapse.replication.http.push import ReplicationCopyPusherRestServlet
|
||||||
from synapse.storage.databases.main.state_deltas import StateDelta
|
from synapse.storage.databases.main.state_deltas import StateDelta
|
||||||
|
from synapse.storage.invite_rule import InviteRule
|
||||||
from synapse.types import (
|
from synapse.types import (
|
||||||
JsonDict,
|
JsonDict,
|
||||||
Requester,
|
Requester,
|
||||||
@@ -915,6 +916,21 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
|
|||||||
additional_fields=block_invite_result[1],
|
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
|
# An empty prev_events list is allowed as long as the auth_event_ids are present
|
||||||
if prev_event_ids is not None:
|
if prev_event_ids is not None:
|
||||||
return await self._local_membership_update(
|
return await self._local_membership_update(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from synapse.storage.databases.main.state import (
|
|||||||
Sentinel as StateSentinel,
|
Sentinel as StateSentinel,
|
||||||
)
|
)
|
||||||
from synapse.storage.databases.main.stream import CurrentStateDeltaMembership
|
from synapse.storage.databases.main.stream import CurrentStateDeltaMembership
|
||||||
|
from synapse.storage.invite_rule import InviteRule
|
||||||
from synapse.storage.roommember import (
|
from synapse.storage.roommember import (
|
||||||
RoomsForUser,
|
RoomsForUser,
|
||||||
RoomsForUserSlidingSync,
|
RoomsForUserSlidingSync,
|
||||||
@@ -278,6 +279,7 @@ class SlidingSyncRoomLists:
|
|||||||
|
|
||||||
# Remove invites from ignored users
|
# Remove invites from ignored users
|
||||||
ignored_users = await self.store.ignored_users(user_id)
|
ignored_users = await self.store.ignored_users(user_id)
|
||||||
|
invite_config = await self.store.get_invite_config_for_user(user_id)
|
||||||
if ignored_users:
|
if ignored_users:
|
||||||
# FIXME: It would be nice to avoid this copy but since
|
# FIXME: It would be nice to avoid this copy but since
|
||||||
# `get_sliding_sync_rooms_for_user_from_membership_snapshots` is cached, it
|
# `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]
|
room_for_user_sliding_sync = room_membership_for_user_map[room_id]
|
||||||
if (
|
if (
|
||||||
room_for_user_sliding_sync.membership == Membership.INVITE
|
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)
|
room_membership_for_user_map.pop(room_id, None)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from synapse.logging.opentracing import (
|
|||||||
from synapse.storage.databases.main.event_push_actions import RoomNotifCounts
|
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.roommember import extract_heroes_from_room_summary
|
||||||
from synapse.storage.databases.main.stream import PaginateFunction
|
from synapse.storage.databases.main.stream import PaginateFunction
|
||||||
|
from synapse.storage.invite_rule import InviteRule
|
||||||
from synapse.storage.roommember import MemberSummary
|
from synapse.storage.roommember import MemberSummary
|
||||||
from synapse.types import (
|
from synapse.types import (
|
||||||
DeviceListUpdates,
|
DeviceListUpdates,
|
||||||
@@ -2549,6 +2550,7 @@ class SyncHandler:
|
|||||||
room_entries: List[RoomSyncResultBuilder] = []
|
room_entries: List[RoomSyncResultBuilder] = []
|
||||||
invited: List[InvitedSyncResult] = []
|
invited: List[InvitedSyncResult] = []
|
||||||
knocked: List[KnockedSyncResult] = []
|
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():
|
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
|
# 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
|
# 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
|
# Only bother if we're still currently invited
|
||||||
should_invite = last_non_join.membership == Membership.INVITE
|
should_invite = last_non_join.membership == Membership.INVITE
|
||||||
if should_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)
|
invite_room_sync = InvitedSyncResult(room_id, invite=last_non_join)
|
||||||
if invite_room_sync:
|
if invite_room_sync:
|
||||||
invited.append(invite_room_sync)
|
invited.append(invite_room_sync)
|
||||||
@@ -2786,6 +2792,7 @@ class SyncHandler:
|
|||||||
membership_list=Membership.LIST,
|
membership_list=Membership.LIST,
|
||||||
excluded_rooms=sync_result_builder.excluded_room_ids,
|
excluded_rooms=sync_result_builder.excluded_room_ids,
|
||||||
)
|
)
|
||||||
|
invite_config = await self.store.get_invite_config_for_user(user_id)
|
||||||
|
|
||||||
room_entries = []
|
room_entries = []
|
||||||
invited = []
|
invited = []
|
||||||
@@ -2811,6 +2818,8 @@ class SyncHandler:
|
|||||||
elif event.membership == Membership.INVITE:
|
elif event.membership == Membership.INVITE:
|
||||||
if event.sender in ignored_users:
|
if event.sender in ignored_users:
|
||||||
continue
|
continue
|
||||||
|
if invite_config.get_invite_rule(event.sender) == InviteRule.IGNORE:
|
||||||
|
continue
|
||||||
invite = await self.store.get_event(event.event_id)
|
invite = await self.store.get_event(event.event_id)
|
||||||
invited.append(InvitedSyncResult(room_id=event.room_id, invite=invite))
|
invited.append(InvitedSyncResult(room_id=event.room_id, invite=invite))
|
||||||
elif event.membership == Membership.KNOCK:
|
elif event.membership == Membership.KNOCK:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from synapse.events.snapshot import EventContext
|
|||||||
from synapse.logging.context import make_deferred_yieldable, run_in_background
|
from synapse.logging.context import make_deferred_yieldable, run_in_background
|
||||||
from synapse.state import POWER_KEY
|
from synapse.state import POWER_KEY
|
||||||
from synapse.storage.databases.main.roommember import EventIdMembership
|
from synapse.storage.databases.main.roommember import EventIdMembership
|
||||||
|
from synapse.storage.invite_rule import InviteRule
|
||||||
from synapse.storage.roommember import ProfileInfo
|
from synapse.storage.roommember import ProfileInfo
|
||||||
from synapse.synapse_rust.push import FilteredPushRules, PushRuleEvaluator
|
from synapse.synapse_rust.push import FilteredPushRules, PushRuleEvaluator
|
||||||
from synapse.types import JsonValue
|
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
|
# 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
|
# 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
|
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)
|
local_users.append(invited)
|
||||||
|
|
||||||
if not local_users:
|
if not local_users:
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ class VersionsRestServlet(RestServlet):
|
|||||||
"org.matrix.simplified_msc3575": msc3575_enabled,
|
"org.matrix.simplified_msc3575": msc3575_enabled,
|
||||||
# Arbitrary key-value profile fields.
|
# Arbitrary key-value profile fields.
|
||||||
"uk.tcpip.msc4133": self.config.experimental.msc4133_enabled,
|
"uk.tcpip.msc4133": self.config.experimental.msc4133_enabled,
|
||||||
|
# MSC4155: Invite filtering
|
||||||
|
"org.matrix.msc4155": self.config.experimental.msc4155_enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from synapse.storage.database import (
|
|||||||
)
|
)
|
||||||
from synapse.storage.databases.main.cache import CacheInvalidationWorkerStore
|
from synapse.storage.databases.main.cache import CacheInvalidationWorkerStore
|
||||||
from synapse.storage.databases.main.push_rule import PushRulesWorkerStore
|
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.storage.util.id_generators import MultiWriterIdGenerator
|
||||||
from synapse.types import JsonDict, JsonMapping
|
from synapse.types import JsonDict, JsonMapping
|
||||||
from synapse.util import json_encoder
|
from synapse.util import json_encoder
|
||||||
@@ -102,6 +103,8 @@ class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore)
|
|||||||
self._delete_account_data_for_deactivated_users,
|
self._delete_account_data_for_deactivated_users,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._msc4155_enabled = hs.config.experimental.msc4155_enabled
|
||||||
|
|
||||||
def get_max_account_data_stream_id(self) -> int:
|
def get_max_account_data_stream_id(self) -> int:
|
||||||
"""Get the current max stream ID for account data stream
|
"""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(
|
def process_replication_rows(
|
||||||
self,
|
self,
|
||||||
stream_name: str,
|
stream_name: str,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ try:
|
|||||||
|
|
||||||
USE_ICU = True
|
USE_ICU = True
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
|
# except ModuleNotFoundError:
|
||||||
USE_ICU = False
|
USE_ICU = False
|
||||||
|
|
||||||
from synapse.api.errors import StoreError
|
from synapse.api.errors import StoreError
|
||||||
|
|||||||
110
synapse/storage/invite_rule.py
Normal file
110
synapse/storage/invite_rule.py
Normal file
@@ -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
|
||||||
@@ -5,10 +5,13 @@ from twisted.test.proto_helpers import MemoryReactor
|
|||||||
import synapse.rest.admin
|
import synapse.rest.admin
|
||||||
import synapse.rest.client.login
|
import synapse.rest.client.login
|
||||||
import synapse.rest.client.room
|
import synapse.rest.client.room
|
||||||
from synapse.api.constants import EventTypes, Membership
|
from synapse.api.constants import AccountDataTypes, EventTypes, Membership
|
||||||
from synapse.api.errors import Codes, LimitExceededError, SynapseError
|
from synapse.api.errors import Codes, LimitExceededError, SynapseError
|
||||||
from synapse.crypto.event_signing import add_hashes_and_signatures
|
from synapse.crypto.event_signing import add_hashes_and_signatures
|
||||||
from synapse.events import FrozenEventV3
|
from synapse.events import FrozenEventV3
|
||||||
|
from synapse.federation.federation_base import (
|
||||||
|
event_from_pdu_json,
|
||||||
|
)
|
||||||
from synapse.federation.federation_client import SendJoinResult
|
from synapse.federation.federation_client import SendJoinResult
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
from synapse.types import UserID, create_requester
|
from synapse.types import UserID, create_requester
|
||||||
@@ -453,3 +456,165 @@ class RoomMemberMasterHandlerTestCase(HomeserverTestCase):
|
|||||||
new_count = rows[0][0]
|
new_count = rows[0][0]
|
||||||
|
|
||||||
self.assertEqual(initial_count, new_count)
|
self.assertEqual(initial_count, new_count)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInviteFiltering(FederatingHomeserverTestCase):
|
||||||
|
servlets = [
|
||||||
|
synapse.rest.admin.register_servlets,
|
||||||
|
synapse.rest.client.login.register_servlets,
|
||||||
|
synapse.rest.client.room.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.handler = hs.get_room_member_handler()
|
||||||
|
self.fed_handler = hs.get_federation_handler()
|
||||||
|
self.store = hs.get_datastores().main
|
||||||
|
|
||||||
|
# Create three users.
|
||||||
|
self.alice = self.register_user("alice", "pass")
|
||||||
|
self.alice_token = self.login("alice", "pass")
|
||||||
|
self.bob = self.register_user("bob", "pass")
|
||||||
|
self.bob_token = self.login("bob", "pass")
|
||||||
|
|
||||||
|
@override_config({"experimental_features": {"msc4155_enabled": True}})
|
||||||
|
def test_misc4155_block_invite_local(self) -> None:
|
||||||
|
"""Test that MSC4155 will block a user from being invited to a room"""
|
||||||
|
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
|
||||||
|
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
self.bob,
|
||||||
|
AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG,
|
||||||
|
{
|
||||||
|
"blocked_users": [self.alice],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
f = self.get_failure(
|
||||||
|
self.handler.update_membership(
|
||||||
|
requester=create_requester(self.alice),
|
||||||
|
target=UserID.from_string(self.bob),
|
||||||
|
room_id=room_id,
|
||||||
|
action=Membership.INVITE,
|
||||||
|
),
|
||||||
|
SynapseError,
|
||||||
|
).value
|
||||||
|
self.assertEqual(f.code, 403)
|
||||||
|
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
|
||||||
|
|
||||||
|
@override_config({"experimental_features": {"msc4155_enabled": False}})
|
||||||
|
def test_msc4155_disabled_allow_invite_local(self) -> None:
|
||||||
|
"""Test that MSC4155 will block a user from being invited to a room"""
|
||||||
|
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
|
||||||
|
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
self.bob,
|
||||||
|
AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG,
|
||||||
|
{
|
||||||
|
"blocked_users": [self.alice],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.get_success(
|
||||||
|
self.handler.update_membership(
|
||||||
|
requester=create_requester(self.alice),
|
||||||
|
target=UserID.from_string(self.bob),
|
||||||
|
room_id=room_id,
|
||||||
|
action=Membership.INVITE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_config({"experimental_features": {"msc4155_enabled": True}})
|
||||||
|
def test_msc4155_block_invite_remote(self) -> None:
|
||||||
|
"""Test that MSC4155 will block a remote user from being invited to a room"""
|
||||||
|
# A remote user who sends the invite
|
||||||
|
remote_server = "otherserver"
|
||||||
|
remote_user = "@otheruser:" + remote_server
|
||||||
|
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
self.bob,
|
||||||
|
AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG,
|
||||||
|
{"blocked_users": [remote_user]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
room_id = self.helper.create_room_as(
|
||||||
|
room_creator=self.alice, tok=self.alice_token
|
||||||
|
)
|
||||||
|
room_version = self.get_success(self.store.get_room_version(room_id))
|
||||||
|
|
||||||
|
invite_event = event_from_pdu_json(
|
||||||
|
{
|
||||||
|
"type": EventTypes.Member,
|
||||||
|
"content": {"membership": "invite"},
|
||||||
|
"room_id": room_id,
|
||||||
|
"sender": remote_user,
|
||||||
|
"state_key": self.bob,
|
||||||
|
"depth": 32,
|
||||||
|
"prev_events": [],
|
||||||
|
"auth_events": [],
|
||||||
|
"origin_server_ts": self.clock.time_msec(),
|
||||||
|
},
|
||||||
|
room_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
f = self.get_failure(
|
||||||
|
self.fed_handler.on_invite_request(
|
||||||
|
remote_server,
|
||||||
|
invite_event,
|
||||||
|
invite_event.room_version,
|
||||||
|
),
|
||||||
|
SynapseError,
|
||||||
|
).value
|
||||||
|
self.assertEqual(f.code, 403)
|
||||||
|
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
|
||||||
|
|
||||||
|
@override_config({"experimental_features": {"msc4155_enabled": True}})
|
||||||
|
def test_msc4155_block_invite_remote_server(self) -> None:
|
||||||
|
"""Test that MSC4155 will block a remote server's user from being invited to a room"""
|
||||||
|
# A remote user who sends the invite
|
||||||
|
remote_server = "otherserver"
|
||||||
|
remote_user = "@otheruser:" + remote_server
|
||||||
|
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
self.bob,
|
||||||
|
AccountDataTypes.MSC4155_INVITE_PERMISSION_CONFIG,
|
||||||
|
{"blocked_servers": [remote_server]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
room_id = self.helper.create_room_as(
|
||||||
|
room_creator=self.alice, tok=self.alice_token
|
||||||
|
)
|
||||||
|
room_version = self.get_success(self.store.get_room_version(room_id))
|
||||||
|
|
||||||
|
invite_event = event_from_pdu_json(
|
||||||
|
{
|
||||||
|
"type": EventTypes.Member,
|
||||||
|
"content": {"membership": "invite"},
|
||||||
|
"room_id": room_id,
|
||||||
|
"sender": remote_user,
|
||||||
|
"state_key": self.bob,
|
||||||
|
"depth": 32,
|
||||||
|
"prev_events": [],
|
||||||
|
"auth_events": [],
|
||||||
|
"origin_server_ts": self.clock.time_msec(),
|
||||||
|
},
|
||||||
|
room_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
f = self.get_failure(
|
||||||
|
self.fed_handler.on_invite_request(
|
||||||
|
remote_server,
|
||||||
|
invite_event,
|
||||||
|
invite_event.room_version,
|
||||||
|
),
|
||||||
|
SynapseError,
|
||||||
|
).value
|
||||||
|
self.assertEqual(f.code, 403)
|
||||||
|
self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
|
||||||
|
|||||||
167
tests/storage/test_invite_rule.py
Normal file
167
tests/storage/test_invite_rule.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from synapse.storage.invite_rule import InviteRule, InviteRulesConfig
|
||||||
|
from synapse.types import UserID
|
||||||
|
|
||||||
|
from tests import unittest
|
||||||
|
|
||||||
|
regular_user = UserID.from_string("@test:example.org")
|
||||||
|
allowed_user = UserID.from_string("@allowed:allow.example.org")
|
||||||
|
blocked_user = UserID.from_string("@blocked:block.example.org")
|
||||||
|
ignored_user = UserID.from_string("@ignored:ignore.example.org")
|
||||||
|
|
||||||
|
|
||||||
|
class InviteFilterTestCase(unittest.TestCase):
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
"""Permit by default"""
|
||||||
|
config = InviteRulesConfig(None)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(regular_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ignore_invalid(self) -> None:
|
||||||
|
"""Invalid strings are ignored"""
|
||||||
|
config = InviteRulesConfig({"blocked_users": ["not a user"]})
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(blocked_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_blocked(self) -> None:
|
||||||
|
"""Permit all, except explicitly blocked users"""
|
||||||
|
config = InviteRulesConfig({"blocked_users": [blocked_user.to_string()]})
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(blocked_user.to_string()), InviteRule.BLOCK
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(regular_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_ignored(self) -> None:
|
||||||
|
"""Permit all, except explicitly ignored users"""
|
||||||
|
config = InviteRulesConfig({"ignored_users": [ignored_user.to_string()]})
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(ignored_user.to_string()), InviteRule.IGNORE
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(regular_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_precedence(self) -> None:
|
||||||
|
"""Always take allowed over ignored, ignored over blocked, and then block."""
|
||||||
|
config = InviteRulesConfig(
|
||||||
|
{
|
||||||
|
"allowed_users": [allowed_user.to_string()],
|
||||||
|
"ignored_users": [allowed_user.to_string(), ignored_user.to_string()],
|
||||||
|
"blocked_users": [
|
||||||
|
allowed_user.to_string(),
|
||||||
|
ignored_user.to_string(),
|
||||||
|
blocked_user.to_string(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(allowed_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(ignored_user.to_string()), InviteRule.IGNORE
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(blocked_user.to_string()), InviteRule.BLOCK
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_blocked(self) -> None:
|
||||||
|
"""Block all users on the server except those allowed."""
|
||||||
|
user_on_same_server = UserID("blocked", allowed_user.domain)
|
||||||
|
config = InviteRulesConfig(
|
||||||
|
{
|
||||||
|
"allowed_users": [allowed_user.to_string()],
|
||||||
|
"blocked_servers": [allowed_user.domain],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(allowed_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(user_on_same_server.to_string()), InviteRule.BLOCK
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_ignored(self) -> None:
|
||||||
|
"""Ignore all users on the server except those allowed."""
|
||||||
|
user_on_same_server = UserID("ignored", allowed_user.domain)
|
||||||
|
config = InviteRulesConfig(
|
||||||
|
{
|
||||||
|
"allowed_users": [allowed_user.to_string()],
|
||||||
|
"ignored_servers": [allowed_user.domain],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(allowed_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(user_on_same_server.to_string()), InviteRule.IGNORE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_allow(self) -> None:
|
||||||
|
"""Allow all from a server except explictly blocked or ignored users."""
|
||||||
|
blocked_user_on_same_server = UserID("blocked", allowed_user.domain)
|
||||||
|
ignored_user_on_same_server = UserID("ignored", allowed_user.domain)
|
||||||
|
allowed_user_on_same_server = UserID("another", allowed_user.domain)
|
||||||
|
config = InviteRulesConfig(
|
||||||
|
{
|
||||||
|
"ignored_users": [ignored_user_on_same_server.to_string()],
|
||||||
|
"blocked_users": [blocked_user_on_same_server.to_string()],
|
||||||
|
"allowed_servers": [allowed_user.to_string()],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(allowed_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(allowed_user_on_same_server.to_string()),
|
||||||
|
InviteRule.ALLOW,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(blocked_user_on_same_server.to_string()),
|
||||||
|
InviteRule.BLOCK,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(ignored_user_on_same_server.to_string()),
|
||||||
|
InviteRule.IGNORE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_precedence(self) -> None:
|
||||||
|
"""Always take allowed over ignored, ignored over blocked, and then block."""
|
||||||
|
config = InviteRulesConfig(
|
||||||
|
{
|
||||||
|
"allowed_servers": [allowed_user.domain],
|
||||||
|
"ignored_servers": [allowed_user.domain, ignored_user.domain],
|
||||||
|
"blocked_servers": [
|
||||||
|
allowed_user.domain,
|
||||||
|
ignored_user.domain,
|
||||||
|
blocked_user.domain,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(allowed_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(ignored_user.to_string()), InviteRule.IGNORE
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(blocked_user.to_string()), InviteRule.BLOCK
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_glob(self) -> None:
|
||||||
|
"""Test that glob patterns match"""
|
||||||
|
config = InviteRulesConfig({"blocked_servers": ["*.example.org"]})
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(allowed_user.to_string()), InviteRule.BLOCK
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(ignored_user.to_string()), InviteRule.BLOCK
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(blocked_user.to_string()), InviteRule.BLOCK
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.get_invite_rule(regular_user.to_string()), InviteRule.ALLOW
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user