Implement MSC4233: remember which server a user knocked through

Fixes rescinding and denying knocks over federation (#18030):

- Remember the server that fulfilled our /send_knock in a new
  local_knock_via_servers table.
- Route a rescission of the knock (make_leave/send_leave) through that
  server, instead of only rescinding locally.
- Send a leave/ban denying a knock to the knocking user's server (the
  same mechanism as invite rescissions), and accept such an event as an
  out-of-band retraction on the knocking server when it references the
  knock in its auth events and comes from the server we knocked
  through (or the denying user's own server).

Gated behind the experimental msc4233_enabled flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG
This commit is contained in:
Matthew Hodgson
2026-07-14 16:14:12 +01:00
co-authored by Claude Fable 5
parent ec6c53e7df
commit c464be40ac
10 changed files with 804 additions and 27 deletions
+1
View File
@@ -0,0 +1 @@
Add experimental support for [MSC4233](https://github.com/matrix-org/matrix-spec-proposals/pull/4233): remember which server a user knocked through, so that knocks can be rescinded and denied over federation.
+4
View File
@@ -256,6 +256,10 @@ class ExperimentalConfig(Config):
# MSC4222: Adding `state_after` to sync v2
self.msc4222_enabled: bool = experimental.get("msc4222_enabled", False)
# MSC4233: Remembering which server a user knocked through, so that
# knocks can be rescinded and denied over federation.
self.msc4233_enabled: bool = experimental.get("msc4233_enabled", False)
# MSC4076: Add `disable_badge_count`` to pusher configuration
self.msc4076_enabled: bool = experimental.get("msc4076_enabled", False)
+18 -9
View File
@@ -126,6 +126,15 @@ class SendJoinResult:
servers_in_room: AbstractSet[str]
@attr.s(slots=True, frozen=True, auto_attribs=True)
class SendKnockResult:
# The response body from the remote server, of the form
# {"knock_room_state": [<state event dict>, ...]}.
response: JsonDict
# The server which fulfilled the knock (i.e. answered our /send_knock).
origin: str
class FederationClient(FederationBase):
def __init__(self, hs: "HomeServer"):
super().__init__(hs)
@@ -1437,7 +1446,9 @@ class FederationClient(FederationBase):
# content.
return resp[1]
async def send_knock(self, destinations: list[str], pdu: EventBase) -> JsonDict:
async def send_knock(
self, destinations: list[str], pdu: EventBase
) -> "SendKnockResult":
"""Attempts to send a knock event to a given list of servers. Iterates
through the list until one attempt succeeds.
@@ -1450,20 +1461,18 @@ class FederationClient(FederationBase):
pdu: The event to be sent.
Returns:
The remote homeserver return some state from the room. The response
dictionary is in the form:
{"knock_room_state": [<state event dict>, ...]}
The list of state events may be empty.
A SendKnockResult holding the remote homeserver's response (some
state from the room, possibly empty) and the name of the server
that fulfilled the knock.
Raises:
SynapseError: If the chosen remote server returns a 3xx/4xx code.
RuntimeError: If no servers were reachable.
"""
async def send_request(destination: str) -> JsonDict:
return await self._do_send_knock(destination, pdu)
async def send_request(destination: str) -> "SendKnockResult":
response = await self._do_send_knock(destination, pdu)
return SendKnockResult(response=response, origin=destination)
return await self._try_destination_list(
"send_knock", destinations, send_request
+22 -8
View File
@@ -669,18 +669,23 @@ class FederationSender(AbstractFederationSender):
)
return
# If we've rescinded an invite then we want to tell the
# other server.
# If we've rescinded an invite, or denied a knock
# (MSC4233), then we want to tell the other server.
msc4233_enabled = self.hs.config.experimental.msc4233_enabled
if (
event.type == EventTypes.Member
and event.membership == Membership.LEAVE
and event.sender != event.state_key
and (
event.membership == Membership.LEAVE
or (msc4233_enabled and event.membership == Membership.BAN)
)
):
# We check if this leave event is rescinding an invite
# by looking if there is an invite event for the user in
# the auth events. It could otherwise be a kick or
# unban, which we don't want to send (if the user wasn't
# already in the room).
# (or denying a knock) by looking if there is an invite
# (or knock) event for the user in the auth events. It
# could otherwise be a kick or unban, which we don't
# want to send (if the user wasn't already in the
# room).
auth_events = await self.store.get_events_as_list(
event.auth_event_ids()
)
@@ -688,7 +693,16 @@ class FederationSender(AbstractFederationSender):
if (
auth_event.type == EventTypes.Member
and auth_event.state_key == event.state_key
and auth_event.membership == Membership.INVITE
and (
(
event.membership == Membership.LEAVE
and auth_event.membership == Membership.INVITE
)
or (
msc4233_enabled
and auth_event.membership == Membership.KNOCK
)
)
):
destinations = set(destinations)
destinations.add(get_domain_from_id(event.state_key))
+23 -1
View File
@@ -886,7 +886,18 @@ class FederationHandler:
# Send the signed event back to the room, and potentially receive some
# further information about the room in the form of partial state events
knock_response = await self.federation_client.send_knock(target_hosts, event)
send_knock_result = await self.federation_client.send_knock(target_hosts, event)
knock_response = send_knock_result.response
# Remember which server fulfilled the knock, so that we can route a
# rescission of the knock through it later, and know which server to
# trust for an out-of-band retraction of the knock (MSC4233).
await self.store.store_local_knock_via_server(
user_id=knockee,
room_id=event.room_id,
via_server=send_knock_result.origin,
knock_event_id=event.event_id,
)
# Store any stripped room state events in the "unsigned" key of the event.
# This is a bit of a hack and is cribbing off of invites. Basically we
@@ -1162,6 +1173,17 @@ class FederationHandler:
return event
async def do_remotely_rescind_knock(
self, target_hosts: Iterable[str], room_id: str, user_id: str, content: JsonDict
) -> tuple[EventBase, int]:
"""Rescind a knock on a remote room: the same make_leave/send_leave
dance as rejecting an invite, routed through the server(s) given
(normally the server the knock was fulfilled through, per MSC4233).
"""
return await self.do_remotely_reject_invite(
target_hosts, room_id, user_id, content
)
async def do_remotely_reject_invite(
self, target_hosts: Iterable[str], room_id: str, user_id: str, content: JsonDict
) -> tuple[EventBase, int]:
+40 -3
View File
@@ -256,12 +256,17 @@ class FederationEventHandler:
room_id, self.server_name
)
if not is_in_room:
# Check if this is a leave event rescinding an invite
# Check if this is a leave event rescinding an invite, or denying
# a knock (MSC4233)
msc4233_enabled = self._config.experimental.msc4233_enabled
if (
pdu.type == EventTypes.Member
and pdu.membership == Membership.LEAVE
and pdu.state_key != pdu.sender
and self._is_mine_id(pdu.state_key)
and (
pdu.membership == Membership.LEAVE
or (msc4233_enabled and pdu.membership == Membership.BAN)
)
):
(
membership,
@@ -270,7 +275,8 @@ class FederationEventHandler:
pdu.state_key, pdu.room_id
)
if (
membership == Membership.INVITE
pdu.membership == Membership.LEAVE
and membership == Membership.INVITE
and membership_event_id
and membership_event_id
in pdu.auth_event_ids() # The invite should be in the auth events of the rescission.
@@ -292,6 +298,37 @@ class FederationEventHandler:
context = EventContext.for_outlier(self._storage_controllers)
await self.persist_events_and_notify(room_id, [(pdu, context)])
return
elif (
msc4233_enabled
and membership == Membership.KNOCK
and membership_event_id
and membership_event_id
in pdu.auth_event_ids() # The knock should be in the auth events of the denial.
):
# A local user's knock on this remote room is being denied
# (or the user banned).
#
# We cannot fully auth the denial event, but the sender has
# demonstrated knowledge of our knock by referencing it in
# the auth events, and the event's signature (checked on
# receipt) proves it comes from the sender's server. On top
# of that, only accept the event from the server the knock
# was fulfilled through, or from the denying user's own
# server.
#
# Technically we cannot verify that the sender has the
# power level required to deny the knock, but the same
# holds for invite rescission above.
via_server = await self._store.get_local_knock_via_server(
pdu.state_key, pdu.room_id
)
if origin == via_server or origin == get_domain_from_id(pdu.sender):
# Handle the denial event
pdu.internal_metadata.outlier = True
pdu.internal_metadata.out_of_band_membership = True
context = EventContext.for_outlier(self._storage_controllers)
await self.persist_events_and_notify(room_id, [(pdu, context)])
return
logger.info(
"Ignoring PDU from %s as we're not in the room",
+31 -6
View File
@@ -2086,13 +2086,38 @@ class RoomMemberMasterHandler(RoomMemberHandler):
Implements RoomMemberHandler.remote_rescind_knock
"""
# TODO: We don't yet support rescinding knocks over federation
# as we don't know which homeserver to send it to. An obvious
# candidate is the remote homeserver we originally knocked through,
# however we don't currently store that information.
# Just rescind the knock locally
knock_event = await self.store.get_event(knock_event_id)
if self.hs.config.experimental.msc4233_enabled:
# MSC4233: route the rescission through the server the knock was
# fulfilled through, which we remembered at knock time.
via_server = await self.store.get_local_knock_via_server(
knock_event.state_key, knock_event.room_id
)
if via_server is not None:
try:
(
event,
stream_id,
) = await self.federation_handler.do_remotely_rescind_knock(
[via_server],
knock_event.room_id,
knock_event.state_key,
content,
)
return event.event_id, stream_id
except Exception as e:
# If we can't reach the remote server, fall back to
# rescinding the knock locally, as we would have done
# before MSC4233.
logger.warning(
"Failed to rescind knock on %s via %s: %s",
knock_event.room_id,
via_server,
e,
)
# Rescind the knock locally
return await self._generate_local_out_of_band_leave(
knock_event, txn_id, requester, content
)
@@ -685,6 +685,47 @@ class RoomMemberWorkerStore(EventsWorkerStore, CacheInvalidationWorkerStore):
return results
async def store_local_knock_via_server(
self, user_id: str, room_id: str, via_server: str, knock_event_id: str
) -> None:
"""Record the remote server that fulfilled a local user's knock on a
remote room, so that a later rescission can be routed through it
(MSC4233). Overwrites any previous record for the user/room pair.
Args:
user_id: The ID of the knocking (local) user.
room_id: The ID of the room that was knocked on.
via_server: The remote server that answered our /send_knock.
knock_event_id: The event ID of the knock membership event.
"""
await self.db_pool.simple_upsert(
table="local_knock_via_servers",
keyvalues={"user_id": user_id, "room_id": room_id},
values={"via_server": via_server, "knock_event_id": knock_event_id},
desc="store_local_knock_via_server",
)
async def get_local_knock_via_server(
self, user_id: str, room_id: str
) -> str | None:
"""Retrieve the remote server that fulfilled a local user's knock on a
remote room, if we have a record of one (MSC4233).
Args:
user_id: The ID of the knocking (local) user.
room_id: The ID of the room that was knocked on.
Returns:
The server name, or None if no knock route is recorded.
"""
return await self.db_pool.simple_select_one_onecol(
table="local_knock_via_servers",
keyvalues={"user_id": user_id, "room_id": room_id},
retcol="via_server",
allow_none=True,
desc="get_local_knock_via_server",
)
async def get_users_server_still_shares_room_with(
self, user_ids: Collection[str]
) -> set[str]:
@@ -0,0 +1,27 @@
--
-- This file is licensed under the Affero General Public License (AGPL) version 3.
--
-- Copyright (C) 2026 New Vector, Ltd
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as
-- published by the Free Software Foundation, either version 3 of the
-- License, or (at your option) any later version.
--
-- See the GNU Affero General Public License for more details:
-- <https://www.gnu.org/licenses/agpl-3.0.html>.
-- Records the remote server that fulfilled a local user's knock on a remote
-- room (i.e. the server that answered our /send_knock request), so that we can
-- route a subsequent rescission of the knock through the same server, and
-- so that we know which server to trust for out-of-band retractions of the
-- knock (MSC4233).
CREATE TABLE local_knock_via_servers (
user_id TEXT NOT NULL,
room_id TEXT NOT NULL,
-- The server the knock was fulfilled through.
via_server TEXT NOT NULL,
-- The event ID of the knock membership event.
knock_event_id TEXT NOT NULL,
CONSTRAINT local_knock_via_servers_uniqueness UNIQUE (user_id, room_id)
);
@@ -0,0 +1,597 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""Tests for MSC4233: remembering which server a user knocked through, so
that knocks can be rescinded and denied over federation."""
import logging
import time
import urllib.parse
from http import HTTPStatus
from typing import Any, Callable, TypeVar
from unittest.mock import Mock
import attr
from twisted.internet.testing import MemoryReactor
from synapse.api.constants import EventContentFields, EventTypes, Membership
from synapse.api.room_versions import RoomVersion, RoomVersions
from synapse.events import EventBase, builder
from synapse.events.snapshot import EventContext
from synapse.events.utils import strip_event
from synapse.http.matrixfederationclient import ByteParser
from synapse.http.types import QueryParams
from synapse.rest import admin
from synapse.rest.client import knock, login, room
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
from tests import unittest
from tests.test_utils.event_builders import make_test_event
from tests.utils import test_timeout
logger = logging.getLogger(__name__)
T = TypeVar("T")
@attr.s(slots=True, auto_attribs=True)
class RemoteRoomKnockResult:
remote_room_id: str
room_version: RoomVersion
remote_room_creator_user_id: str
local_user1_id: str
local_user1_tok: str
room_create_event: EventBase
knock_event_id: str
class KnockViaServerTestCase(unittest.FederatingHomeserverTestCase):
"""
Tests for the knocking server's side of MSC4233:
- the server that fulfilled our /send_knock is remembered
- a rescission of the knock is routed through that server
- a denial of the knock sent to us by that server is accepted as an
out-of-band retraction of the knock
"""
servlets = [
admin.register_servlets,
knock.register_servlets,
room.register_servlets,
login.register_servlets,
]
def default_config(self) -> JsonDict:
conf = super().default_config()
conf["experimental_features"] = {"msc4233_enabled": True}
return conf
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
self.federation_http_client = Mock()
return self.setup_test_homeserver(
federation_http_client=self.federation_http_client
)
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
super().prepare(reactor, clock, hs)
self.store = self.hs.get_datastores().main
def _knock_on_remote_room(self) -> RemoteRoomKnockResult:
"""Have a local user knock on a (mocked) remote room via the remote
server, and return the details of the knock."""
local_user1_id = self.register_user("user1", "pass")
local_user1_tok = self.login(local_user1_id, "pass")
room_creator_user_id = f"@remote-user:{self.OTHER_SERVER_NAME}"
remote_room_id = f"!remote-room:{self.OTHER_SERVER_NAME}"
room_version = RoomVersions.V10
room_create_event = make_test_event(
self.add_hashes_and_signatures_from_other_server(
{
"room_id": remote_room_id,
"sender": room_creator_user_id,
"depth": 1,
"origin_server_ts": 1,
"type": EventTypes.Create,
"state_key": "",
"content": {
EventContentFields.ROOM_CREATOR: room_creator_user_id,
EventContentFields.ROOM_VERSION: room_version.identifier,
},
"auth_events": [],
"prev_events": [],
}
),
room_version=room_version,
)
# A knock event template, as the remote server would return from
# /make_knock.
knock_event_template = {
"room_id": remote_room_id,
"sender": local_user1_id,
"depth": 2,
"origin_server_ts": 2,
"type": EventTypes.Member,
"state_key": local_user1_id,
"content": {"membership": Membership.KNOCK},
"auth_events": [room_create_event.event_id],
"prev_events": [room_create_event.event_id],
}
async def get_json(
destination: str,
path: str,
args: QueryParams | None = None,
retry_on_dns_fail: bool = True,
timeout: int | None = None,
ignore_backoff: bool = False,
try_trailing_slash_on_400: bool = False,
parser: ByteParser[T] | None = None,
) -> JsonDict | T:
if path.startswith(
f"/_matrix/federation/v1/make_knock/{urllib.parse.quote_plus(remote_room_id)}/{urllib.parse.quote_plus(local_user1_id)}"
):
return {
"event": knock_event_template,
"room_version": room_version.identifier,
}
raise NotImplementedError(
f"Unmocked `get_json(...)` endpoint: {destination}{path}"
)
self.federation_http_client.get_json.side_effect = get_json
# Record which server(s) we called /send_knock on.
send_knock_destinations: list[str] = []
async def put_json(
destination: str,
path: str,
args: QueryParams | None = None,
data: JsonDict | None = None,
json_data_callback: Callable[[], JsonDict] | None = None,
long_retries: bool = False,
timeout: int | None = None,
ignore_backoff: bool = False,
backoff_on_404: bool = False,
try_trailing_slash_on_400: bool = False,
parser: ByteParser[T] | None = None,
backoff_on_all_error_codes: bool = False,
) -> JsonDict | T:
if path.startswith(
f"/_matrix/federation/v1/send_knock/{urllib.parse.quote_plus(remote_room_id)}/"
):
send_knock_destinations.append(destination)
return {"knock_room_state": [strip_event(room_create_event)]}
raise NotImplementedError(
f"Unmocked `put_json(...)` endpoint: {destination}{path} with body {data}"
)
self.federation_http_client.put_json.side_effect = put_json
# User1 knocks on the remote room
channel = self.make_request(
"POST",
f"/_matrix/client/v3/knock/{urllib.parse.quote_plus(remote_room_id)}?server_name={self.OTHER_SERVER_NAME}",
{},
access_token=local_user1_tok,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
# Reset the mocks now that the knock has completed
self.federation_http_client.get_json.side_effect = None
self.federation_http_client.put_json.side_effect = None
self.assertEqual(send_knock_destinations, [self.OTHER_SERVER_NAME])
# Our local membership should now be an (out-of-band) knock
membership, knock_event_id = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
local_user1_id, remote_room_id
)
)
self.assertEqual(membership, Membership.KNOCK)
assert knock_event_id is not None
return RemoteRoomKnockResult(
remote_room_id=remote_room_id,
room_version=room_version,
remote_room_creator_user_id=room_creator_user_id,
local_user1_id=local_user1_id,
local_user1_tok=local_user1_tok,
room_create_event=room_create_event,
knock_event_id=knock_event_id,
)
def test_knock_remembers_via_server(self) -> None:
"""The server that fulfilled our /send_knock is recorded."""
knock_result = self._knock_on_remote_room()
via_server = self.get_success(
self.store.get_local_knock_via_server(
knock_result.local_user1_id, knock_result.remote_room_id
)
)
self.assertEqual(via_server, self.OTHER_SERVER_NAME)
def test_rescind_knock_routed_through_via_server(self) -> None:
"""Rescinding a knock does the make_leave/send_leave dance through the
server the knock was fulfilled through."""
knock_result = self._knock_on_remote_room()
remote_room_id = knock_result.remote_room_id
local_user1_id = knock_result.local_user1_id
# A leave event template, as the remote server would return from
# /make_leave.
leave_event_template = {
"room_id": remote_room_id,
"sender": local_user1_id,
"depth": 3,
"origin_server_ts": 3,
"type": EventTypes.Member,
"state_key": local_user1_id,
"content": {"membership": Membership.LEAVE},
"auth_events": [
knock_result.room_create_event.event_id,
knock_result.knock_event_id,
],
"prev_events": [knock_result.knock_event_id],
}
make_leave_destinations: list[str] = []
send_leave_destinations: list[str] = []
async def get_json(
destination: str,
path: str,
args: QueryParams | None = None,
retry_on_dns_fail: bool = True,
timeout: int | None = None,
ignore_backoff: bool = False,
try_trailing_slash_on_400: bool = False,
parser: ByteParser[T] | None = None,
) -> JsonDict | T:
if path.startswith(
f"/_matrix/federation/v1/make_leave/{urllib.parse.quote_plus(remote_room_id)}/{urllib.parse.quote_plus(local_user1_id)}"
):
make_leave_destinations.append(destination)
return {
"event": leave_event_template,
"room_version": knock_result.room_version.identifier,
}
raise NotImplementedError(
f"Unmocked `get_json(...)` endpoint: {destination}{path}"
)
self.federation_http_client.get_json.side_effect = get_json
async def put_json(
destination: str,
path: str,
args: QueryParams | None = None,
data: JsonDict | None = None,
json_data_callback: Callable[[], JsonDict] | None = None,
long_retries: bool = False,
timeout: int | None = None,
ignore_backoff: bool = False,
backoff_on_404: bool = False,
try_trailing_slash_on_400: bool = False,
parser: ByteParser[T] | None = None,
backoff_on_all_error_codes: bool = False,
) -> JsonDict | T:
if path.startswith(
f"/_matrix/federation/v2/send_leave/{urllib.parse.quote_plus(remote_room_id)}/"
):
send_leave_destinations.append(destination)
return {}
raise NotImplementedError(
f"Unmocked `put_json(...)` endpoint: {destination}{path} with body {data}"
)
self.federation_http_client.put_json.side_effect = put_json
# User1 rescinds the knock
channel = self.make_request(
"POST",
f"/_matrix/client/v3/rooms/{urllib.parse.quote_plus(remote_room_id)}/leave",
{},
access_token=knock_result.local_user1_tok,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
# The rescission should have been routed through the server we
# knocked through.
self.assertEqual(make_leave_destinations, [self.OTHER_SERVER_NAME])
self.assertEqual(send_leave_destinations, [self.OTHER_SERVER_NAME])
# And our local membership should now be leave.
membership, _ = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
local_user1_id, remote_room_id
)
)
self.assertEqual(membership, Membership.LEAVE)
def test_rescind_knock_falls_back_to_local_leave(self) -> None:
"""If the server we knocked through is unreachable, the knock is still
rescinded locally."""
knock_result = self._knock_on_remote_room()
async def get_json(*args: Any, **kwargs: Any) -> JsonDict:
raise RuntimeError("server unreachable")
self.federation_http_client.get_json.side_effect = get_json
channel = self.make_request(
"POST",
f"/_matrix/client/v3/rooms/{urllib.parse.quote_plus(knock_result.remote_room_id)}/leave",
{},
access_token=knock_result.local_user1_tok,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
membership, _ = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
knock_result.local_user1_id, knock_result.remote_room_id
)
)
self.assertEqual(membership, Membership.LEAVE)
def _send_denial_over_federation(
self, knock_result: RemoteRoomKnockResult, auth_events: list[str]
) -> None:
"""Send a leave event denying the knock to our server, as the remote
server we knocked through."""
deny_event = make_test_event(
self.add_hashes_and_signatures_from_other_server(
{
"room_id": knock_result.remote_room_id,
"sender": knock_result.remote_room_creator_user_id,
"depth": 3,
"origin_server_ts": 3,
"type": EventTypes.Member,
"state_key": knock_result.local_user1_id,
"content": {"membership": Membership.LEAVE},
"auth_events": auth_events,
"prev_events": [knock_result.knock_event_id],
}
),
room_version=knock_result.room_version,
)
channel = self.make_signed_federation_request(
"PUT",
"/_matrix/federation/v1/send/txn_deny_knock",
{"pdus": [deny_event.get_pdu_json()]},
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
def test_denied_knock_accepted_from_via_server(self) -> None:
"""A leave event for our knocked user, referencing the knock in its
auth events and sent by the server we knocked through, retracts the
knock."""
knock_result = self._knock_on_remote_room()
self._send_denial_over_federation(
knock_result,
auth_events=[
knock_result.room_create_event.event_id,
knock_result.knock_event_id,
],
)
# The knock should (eventually - the PDU is processed in the
# background) be retracted.
with test_timeout(3, "Denial of the knock was not processed"):
while True:
membership, _ = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
knock_result.local_user1_id, knock_result.remote_room_id
)
)
if membership == Membership.LEAVE:
break
time.sleep(0.1)
def test_denied_knock_ignored_without_knock_in_auth_events(self) -> None:
"""A leave event for our knocked user which does not reference the
knock in its auth events is ignored."""
knock_result = self._knock_on_remote_room()
self._send_denial_over_federation(
knock_result,
auth_events=[knock_result.room_create_event.event_id],
)
# Pump the reactor to let the (ignored) PDU get processed.
self.pump(1)
membership, _ = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
knock_result.local_user1_id, knock_result.remote_room_id
)
)
self.assertEqual(membership, Membership.KNOCK)
class DenyKnockFederationSendTestCase(unittest.FederatingHomeserverTestCase):
"""
Tests for the resident server's side of MSC4233: a leave event denying a
knock is sent to the knocking user's server, which is otherwise not in
the room.
"""
servlets = [
admin.register_servlets,
room.register_servlets,
login.register_servlets,
]
def default_config(self) -> JsonDict:
conf = super().default_config()
conf["experimental_features"] = {"msc4233_enabled": True}
# Federation sending is disabled by default in the test environment
# so we need to enable it like this.
conf["federation_sender_instances"] = ["master"]
return conf
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
self.federation_http_client = Mock()
return self.setup_test_homeserver(
federation_http_client=self.federation_http_client
)
def prepare(
self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer
) -> None:
self.store = homeserver.get_datastores().main
# We're not going to be properly signing events as our remote
# homeserver is fake, therefore disable event signature checks.
async def approve_all_signature_checking(
room_version: RoomVersion,
pdu: EventBase,
record_failure_callback: Any = None,
) -> EventBase:
return pdu
homeserver.get_federation_server()._check_sigs_and_hash = ( # type: ignore[method-assign]
approve_all_signature_checking
)
async def _check_event_auth(
origin: str | None, event: EventBase, context: EventContext
) -> None:
pass
homeserver.get_federation_event_handler()._check_event_auth = _check_event_auth # type: ignore[method-assign]
return super().prepare(reactor, clock, homeserver)
def test_deny_sends_leave_to_knocking_server(self) -> None:
"""Kicking a remote user whose membership is knock sends the leave
event to their (otherwise uninvolved) server."""
user_id = self.register_user("u1", "pass")
user_token = self.login("u1", "pass")
fake_knocking_user_id = f"@user:{self.OTHER_SERVER_NAME}"
# Create a knockable room
room_id = self.helper.create_room_as(
"u1",
is_public=False,
room_version=RoomVersions.V10.identifier,
tok=user_token,
)
self.helper.send_state(
room_id,
EventTypes.JoinRules,
{"join_rule": "knock"},
tok=user_token,
)
# Collect the PDUs that our server sends out, per destination.
sent_pdus_by_destination: dict[str, list[JsonDict]] = {}
async def put_json(
destination: str,
path: str,
args: QueryParams | None = None,
data: JsonDict | None = None,
json_data_callback: Callable[[], JsonDict] | None = None,
long_retries: bool = False,
timeout: int | None = None,
ignore_backoff: bool = False,
backoff_on_404: bool = False,
try_trailing_slash_on_400: bool = False,
parser: ByteParser[T] | None = None,
backoff_on_all_error_codes: bool = False,
) -> JsonDict | T:
if path.startswith("/_matrix/federation/v1/send/") and data is not None:
pdus = data.get("pdus", [])
sent_pdus_by_destination.setdefault(destination, []).extend(pdus)
return {}
raise NotImplementedError(
f"Unmocked `put_json(...)` endpoint: {destination}{path}"
)
self.federation_http_client.put_json.side_effect = put_json
# The remote user knocks on the room, over federation.
channel = self.make_signed_federation_request(
"GET",
"/_matrix/federation/v1/make_knock/%s/%s?ver=%s"
% (
room_id,
fake_knocking_user_id,
RoomVersions.V10.identifier,
),
)
self.assertEqual(200, channel.code, channel.result)
knock_event = channel.json_body["event"]
signed_knock_event = builder.create_local_event_from_event_dict(
self.clock,
self.hs.hostname,
self.hs.signing_key,
room_version=RoomVersions.V10,
event_dict=knock_event,
)
channel = self.make_signed_federation_request(
"PUT",
"/_matrix/federation/v1/send_knock/%s/%s"
% (room_id, signed_knock_event.event_id),
signed_knock_event.get_pdu_json(self.clock.time_msec()),
)
self.assertEqual(200, channel.code, channel.result)
# The local user denies the knock.
channel = self.make_request(
"POST",
f"/_matrix/client/v3/rooms/{urllib.parse.quote_plus(room_id)}/kick",
{"user_id": fake_knocking_user_id},
access_token=user_token,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
# The leave event should be sent to the knocking user's server, even
# though it has no other users in the room.
with test_timeout(3, "Leave event was not sent to the knocking server"):
while True:
leave_pdus = [
pdu
for pdu in sent_pdus_by_destination.get(
self.OTHER_SERVER_NAME, []
)
if pdu.get("type") == EventTypes.Member
and pdu.get("state_key") == fake_knocking_user_id
and pdu.get("content", {}).get("membership") == Membership.LEAVE
]
if leave_pdus:
break
time.sleep(0.1)
self.pump(0.1)