Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2eccb4c
Initial implementation of MSC4155
Half-Shot Mar 27, 2025
c7f6f30
Add a versions flag.
Half-Shot Mar 27, 2025
b033b1a
changelog
Half-Shot Mar 27, 2025
9edc201
tweaks
Half-Shot Mar 27, 2025
79ca4f9
tweak comments / block apply order
Half-Shot Mar 31, 2025
eb116af
Buildup tests.
Half-Shot Mar 31, 2025
5a9b62e
Add mixed rules.
Half-Shot Mar 31, 2025
bf1302d
Finish writing tests
Half-Shot Mar 31, 2025
501796c
Merge remote-tracking branch 'origin/develop' into hs/msc4155-invite-…
Half-Shot Mar 31, 2025
729567f
Merge remote-tracking branch 'origin/develop' into hs/msc4155-invite-…
Half-Shot May 22, 2025
055a3d4
Rewrite for new MSC contents
Half-Shot May 29, 2025
0bd92ec
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot May 29, 2025
5a07938
lint
Half-Shot May 29, 2025
58dfba5
Update changelog.d/18288.feature
Half-Shot May 29, 2025
cd5d797
review bits
Half-Shot May 29, 2025
0b87642
Rewrite so that the server notices exception applies inside room_member
Half-Shot May 29, 2025
5d5cbf9
remove cached because it requires true immutability
Half-Shot May 29, 2025
cebeaba
Users can also match globs
Half-Shot May 29, 2025
dcdf812
Add experimental config to complement
Half-Shot May 29, 2025
38ae50d
Revert accidental user directory change
Half-Shot May 29, 2025
e6ad1c3
everything is globs /o\
Half-Shot May 29, 2025
d72a884
cleanup error handling
Half-Shot May 29, 2025
b455dea
lint
Half-Shot May 29, 2025
652341c
Add msc4155 to test list
Half-Shot May 29, 2025
de55ace
Refactor so that rules are processed in the correct order
Half-Shot May 30, 2025
e967227
Cleanup
Half-Shot May 30, 2025
268c53e
Improve ignore behaviour
Half-Shot Jun 3, 2025
5b696be
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot Jun 3, 2025
2f33aab
Apply suggestions from code review
Half-Shot Jun 4, 2025
cb5207b
revert user directory faffage
Half-Shot Jun 4, 2025
6e77fac
Review changes
Half-Shot Jun 4, 2025
72452b4
Remove capabilites as it was removed from the MSC.
Half-Shot Jun 4, 2025
56cd377
lint
Half-Shot Jun 4, 2025
de1392b
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot Jun 4, 2025
0a6ffc7
Add pydoc
Half-Shot Jun 4, 2025
8063cc6
cleanup
Half-Shot Jun 4, 2025
f810aa9
is state
Half-Shot Jun 4, 2025
9815587
lint
Half-Shot Jun 4, 2025
6d72827
Fix log line
anoadragon453 Jun 4, 2025
1facef6
Merge branch 'develop' into hs/msc4155-invite-filtering
Half-Shot Jun 4, 2025
d9defd8
Add INVITE_BLOCKED error.
Half-Shot Jun 5, 2025
ca76d7b
correct error
Half-Shot Jun 5, 2025
78a69ca
correct error code in tests
Half-Shot Jun 5, 2025
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/18288.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for [MSC4155](https://github.com/matrix-org/matrix-spec-proposals/pull/4155) Invite Filtering.
2 changes: 2 additions & 0 deletions docker/complement/conf/workers-shared-extra.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ experimental_features:
msc3983_appservice_otk_claims: true
# Proxy key queries to exclusive ASes
msc3984_appservice_key_query: true
# Invite filtering
msc4155_enabled: true

server_notices:
system_mxid_localpart: _server
Expand Down
1 change: 1 addition & 0 deletions scripts-dev/complement.sh
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ test_packages=(
./tests/msc3902
./tests/msc3967
./tests/msc4140
./tests/msc4155
)

# Enable dirty runs, so tests will reuse the same container where possible.
Expand Down
4 changes: 4 additions & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,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:
Expand Down
3 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,6 @@ def read_config(
"msc4263_limit_key_queries_to_users_who_share_rooms",
False,
)

# MSC4155: Invite filtering
self.msc4155_enabled: bool = experimental.get("msc4155_enabled", False)
47 changes: 36 additions & 11 deletions synapse/handlers/federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import enum
import itertools
import logging
import random
from enum import Enum
from http import HTTPStatus
from typing import (
Expand Down Expand Up @@ -78,7 +79,8 @@
ReplicationStoreRoomOnOutlierMembershipRestServlet,
)
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
from synapse.types import JsonDict, StrCollection, get_domain_from_id
from synapse.storage.invite_rule import InviteRule
from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id
from synapse.types.state import StateFilter
from synapse.util.async_helpers import Linearizer
from synapse.util.retryutils import NotRetryingDestination
Expand Down Expand Up @@ -1089,6 +1091,28 @@ async def on_invite_request(
if event.state_key == self._server_notices_mxid:
raise SynapseError(HTTPStatus.FORBIDDEN, "Cannot invite this user")

# check the invitee's configuration and apply rules
should_persist = True
invite_config = await self.store.get_invite_config_for_user(event.state_key)
rule = invite_config.get_invite_rule(UserID.from_string(event.sender))
if rule == InviteRule.BLOCK:
logger.info(
f"Automatically rejecting invite from {event.state_key} due to the the invite filtering rules of {event.sender}"
)
raise SynapseError(
403,
"You are not permitted to invite this user.",
errcode=Codes.FORBIDDEN,
)
elif rule == InviteRule.IGNORE:
logger.info(
f"Silently invite from {event.state_key} due to the the invite filtering rules of {event.sender}"
)
# Pretend to do some work to make it look like we persisted the event
await self.clock.sleep(random.randint(1, 5))
# We still send a normal response back to the server as if the event succeeded.
should_persist = False

# 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
Expand All @@ -1114,18 +1138,19 @@ async def on_invite_request(
)
)

context = EventContext.for_outlier(self._storage_controllers)
if should_persist:
context = EventContext.for_outlier(self._storage_controllers)

await self._bulk_push_rule_evaluator.action_for_events_by_user(
[(event, context)]
)
try:
await self._federation_event_handler.persist_events_and_notify(
event.room_id, [(event, context)]
await self._bulk_push_rule_evaluator.action_for_events_by_user(
[(event, context)]
)
except Exception:
await self.store.remove_push_actions_from_staging(event.event_id)
raise
try:
await self._federation_event_handler.persist_events_and_notify(
event.room_id, [(event, context)]
)
except Exception:
await self.store.remove_push_actions_from_staging(event.event_id)
raise

return event

Expand Down
22 changes: 22 additions & 0 deletions synapse/handlers/room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
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,
Expand Down Expand Up @@ -912,6 +913,27 @@ async def update_membership_locked(
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 and self.config.experimental.msc4155_enabled:
invite_config = await self.store.get_invite_config_for_user(target_id)
rule = invite_config.get_invite_rule(requester.user)
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.FORBIDDEN,
)
elif rule == InviteRule.IGNORE:
logger.info(
f"Silently ignoring invite from {target_id} due to the the invite filtering rules of {requester.user}"
)
# Same behaviour as shadow banning, to mimic success.
await self.clock.sleep(random.randint(1, 10))
raise ShadowBanError()

# 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(
Expand Down
7 changes: 7 additions & 0 deletions synapse/rest/client/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
"disallowed"
] = disallowed

if self.config.experimental.msc4155_enabled:
response["capabilities"][
"org.matrix.msc4155.invite_permission_config_enforced"
] = {
"enabled": self.config.experimental.msc4155_enabled,
}

return HTTPStatus.OK, response


Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/client/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
"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,
},
},
)
Expand Down
17 changes: 17 additions & 0 deletions synapse/storage/databases/main/account_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
)
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
Expand Down Expand Up @@ -102,6 +103,8 @@ def __init__(
self._delete_account_data_for_deactivated_users,
)

self._enable_invite_config = hs.config.experimental.msc4155_enabled

def get_max_account_data_stream_id(self) -> int:
"""Get the current max stream ID for account data stream

Expand Down Expand Up @@ -557,6 +560,20 @@ async def ignored_users(self, user_id: str) -> FrozenSet[str]:
)
)

async def get_invite_config_for_user(self, user_id: str) -> InviteRulesConfig:
"""
Get the invite configuration for the current user.
"""

if not self._enable_invite_config:
# 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,
Expand Down
95 changes: 95 additions & 0 deletions synapse/storage/invite_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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) >= 1:
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("Could not process rule '%s': %s", value, 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: UserID) -> InviteRule:
"""Get the invite rule that matches this user. Will return InviteRule.ALLOW if no rules match"""
user_id_str = user_id.to_string()
# 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_str):
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_id.domain):
return rule

return InviteRule.ALLOW
Loading
Loading