mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-11 12:17:54 +00:00
A member key names no server, so a room full of them is a room whose members cannot be routed to, served to, or shown to anyone. What a key stands for is the linkage information on its `m.room.member` event, and this teaches the server to read it: another server's member can now join one of our member key rooms over federation, and we learn who they are from the event that put them there. A member key's link is the linkage information on its *current* `m.room.member` event, and nothing else. A member who has left has no link, because the event that removed them was somebody else's and asserts nothing about them. Making the mapping a function of the room's current state means every server that has the state agrees on it, no history is needed to compute it, and an event arriving late or by backfill cannot change it. Two signatures decide what a claim is worth. The member key's own makes the claim theirs, and without it the claim is ignored entirely: on an event the member sent, the `sender` signature is already that; on an event somebody else sent about them, it is the signature under `state_key`. The current server signing key of the claimed domain makes the claim *verified*, and that is the half that needs the network, so it is the half that can fail for reasons that are nobody's fault: a domain that does not answer leaves the link unverified rather than refused, and unverified links are re-checked on a small bounded loop so that a member whose server was briefly unreachable does not stay invisible until they next change their membership. What a link is used for depends on which half holds. The claimed domain is where the room's traffic goes, verified or not: an unverified claim is somewhere to send to, and grants nothing. Only a verified link makes a server a member server — able to be told the room's history, to be served its state, to be treated as present in it — because anybody can claim anybody's domain. Events from a key with no verified link are soft failed, so their state changes still count (membership and power levels are decided by keys, not by who holds them) but their messages are not shown to clients. Joining a room of this kind works from either end. A joining server does not know the room's version until `/make_join` answers, so it cannot know whether to name its user by ID or by key: it sends the user ID in the path and the key in `org.matrix.msc4430.member_key`, which a server that has never heard of MSC4430 ignores. The key is held in memory until the room turns out to want it, so joining a room without member keys leaves nothing behind, and it is stored before the join is sent, so a join that succeeds is never one we cannot sign for afterwards. A resident server accepts a key in either place and refuses a bare user ID for such a room. Because the sender of a membership event is a key and not a user ID, the check that the sender belongs to the requesting server no longer applies: the origin is simply who relayed it, and what the member claims about their own server is in the event, signed by them. The end-to-end tests build the remote half by hand, so that a server can be made to vouch for its member, decline to, vouch for a different one, or lie about somebody else's. They cover a verified join, an unverified one, a re-check that succeeds on a later attempt, a member key that moves to another server and takes its member with it, and a moderator who bans a member while naming a domain they control and changes nothing about who that member is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1555 lines
61 KiB
Python
1555 lines
61 KiB
Python
#
|
|
# This file is licensed under the Affero General Public License (AGPL) version 3.
|
|
#
|
|
# Copyright 2019 Matrix.org Federation C.I.C
|
|
# Copyright (C) 2023 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>.
|
|
#
|
|
# Originally licensed under the Apache License, Version 2.0:
|
|
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
|
#
|
|
# [This file includes modifications made by New Vector Limited]
|
|
#
|
|
#
|
|
import logging
|
|
from http import HTTPStatus
|
|
from unittest.mock import Mock
|
|
|
|
from parameterized import parameterized
|
|
|
|
from twisted.internet.testing import MemoryReactor
|
|
|
|
from synapse.api.constants import EventTypes, Membership, StateDag
|
|
from synapse.api.errors import Codes, FederationError
|
|
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersions
|
|
from synapse.config.server import DEFAULT_ROOM_VERSION
|
|
from synapse.crypto.event_signing import add_hashes_and_signatures
|
|
from synapse.events import EventBase
|
|
from synapse.events.py_protocol import supports_msc4242_state_dag
|
|
from synapse.http.types import QueryParams
|
|
from synapse.logging.context import LoggingContext
|
|
from synapse.rest import admin
|
|
from synapse.rest.client import login, room
|
|
from synapse.server import HomeServer
|
|
from synapse.storage.controllers.state import server_acl_evaluator_from_event
|
|
from synapse.types import JsonDict, UserID
|
|
from synapse.util.clock import Clock
|
|
|
|
from tests import unittest
|
|
from tests.test_utils.event_builders import make_test_event, make_test_pdu_event
|
|
from tests.unittest import override_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FederationServerTests(unittest.FederatingHomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
@parameterized.expand([(b"",), (b"foo",), (b'{"limit": Infinity}',)])
|
|
def test_bad_request(self, query_content: bytes) -> None:
|
|
"""
|
|
Querying with bad data returns a reasonable error code.
|
|
"""
|
|
u1 = self.register_user("u1", "pass")
|
|
u1_token = self.login("u1", "pass")
|
|
|
|
room_1 = self.helper.create_room_as(u1, tok=u1_token)
|
|
self.inject_room_member(room_1, "@user:other.example.com", "join")
|
|
|
|
"/get_missing_events/(?P<room_id>[^/]*)/?"
|
|
|
|
channel = self.make_request(
|
|
"POST",
|
|
"/_matrix/federation/v1/get_missing_events/%s" % (room_1,),
|
|
query_content,
|
|
)
|
|
self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result)
|
|
self.assertEqual(channel.json_body["errcode"], "M_NOT_JSON")
|
|
|
|
def test_failed_edu_causes_500(self) -> None:
|
|
"""If the EDU handler fails, /send should return a 500."""
|
|
|
|
async def failing_handler(_origin: str, _content: JsonDict) -> None:
|
|
raise Exception("bleh")
|
|
|
|
self.hs.get_federation_registry().register_edu_handler(
|
|
"FAIL_EDU_TYPE", failing_handler
|
|
)
|
|
|
|
channel = self.make_signed_federation_request(
|
|
"PUT",
|
|
"/_matrix/federation/v1/send/txn",
|
|
{"edus": [{"edu_type": "FAIL_EDU_TYPE", "content": {}}]},
|
|
)
|
|
self.assertEqual(500, channel.code, channel.result)
|
|
|
|
|
|
class MakeMembershipUserIdTests(unittest.FederatingHomeserverTestCase):
|
|
"""A room without MSC4430 member keys names its members by user ID, so a
|
|
member key (`@<43 chars>`, which has no domain) is refused — with a 400
|
|
rather than the server error that parsing a domain that is not there would
|
|
give. The same goes for one offered in the query parameter a joining server
|
|
uses when it does not yet know the room version."""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
login.register_servlets,
|
|
room.register_servlets,
|
|
]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
super().prepare(reactor, clock, hs)
|
|
self.room_id = self.helper.create_room_as(
|
|
self.register_user("u1", "pass"), tok=self.login("u1", "pass")
|
|
)
|
|
|
|
MEMBER_KEY = "@l8Hft5qXKn1vfHrg3p4-W8gELQVo8N13JkluMfmn2sQ"
|
|
|
|
@parameterized.expand(["make_join", "make_leave", "make_knock"])
|
|
def test_rejects_member_key_in_path(self, endpoint: str) -> None:
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/{endpoint}/{self.room_id}/{self.MEMBER_KEY}"
|
|
f"?ver={self._room_version}",
|
|
)
|
|
self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result)
|
|
self.assertEqual(channel.json_body["errcode"], Codes.INVALID_PARAM)
|
|
|
|
@parameterized.expand(["make_join", "make_leave"])
|
|
def test_rejects_member_key_in_query(self, endpoint: str) -> None:
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/{endpoint}/{self.room_id}/@u1:other.example"
|
|
f"?ver={self._room_version}"
|
|
f"&org.matrix.msc4430.member_key={self.MEMBER_KEY}",
|
|
)
|
|
self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result)
|
|
self.assertEqual(channel.json_body["errcode"], Codes.INVALID_PARAM)
|
|
|
|
@property
|
|
def _room_version(self) -> str:
|
|
return self.hs.config.server.default_room_version.identifier
|
|
|
|
|
|
class GetMissingEventsRoomCheckTests(unittest.FederatingHomeserverTestCase):
|
|
"""
|
|
Regression tests for room confusion in /get_missing_events
|
|
https://github.com/element-hq/synapse/security/advisories/GHSA-27p5-4f45-gx76
|
|
"""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
login.register_servlets,
|
|
room.register_servlets,
|
|
]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
super().prepare(reactor, clock, hs)
|
|
|
|
# Local user
|
|
self.local_user_id = self.register_user("alice", "pass")
|
|
self.local_user_token = self.login("alice", "pass")
|
|
self.local_user = UserID.from_string(self.local_user_id)
|
|
|
|
# Create 2 rooms (one with the remote server, one without).
|
|
# - The remote server will be in this room
|
|
self.room_allowed = self.helper.create_room_as(
|
|
self.local_user_id, tok=self.local_user_token
|
|
)
|
|
self.inject_room_member(
|
|
self.room_allowed, f"@remote:{self.OTHER_SERVER_NAME}", "join"
|
|
)
|
|
# - The remote server will _not_ be in this room
|
|
self.room_blocked = self.helper.create_room_as(
|
|
self.local_user_id, tok=self.local_user_token
|
|
)
|
|
|
|
# Insert a linear chain of events in both rooms
|
|
self.room_allowed_event_ids = self.helper.send_messages(
|
|
self.room_allowed, num_events=5, tok=self.local_user_token
|
|
)
|
|
self.room_blocked_event_ids = self.helper.send_messages(
|
|
self.room_blocked, num_events=5, tok=self.local_user_token
|
|
)
|
|
|
|
def _extract_returned_event_ids(self, json_body: JsonDict) -> set[str]:
|
|
"""
|
|
Given the response body of `/get_missing_events`, return the event IDs
|
|
of the events that were returned in the response.
|
|
This only includes event IDs from `self.room_allowed_event_ids` and
|
|
`self.room_blocked_event_ids`; other events are ignored.
|
|
|
|
As the federation PDU format doesn't include event IDs
|
|
(at least not for every room version), we match on the
|
|
`(room_id, content.body, prev_events)` triple against the events
|
|
we sent in the setup.
|
|
"""
|
|
store = self.hs.get_datastores().main
|
|
events = self.get_success(
|
|
store.get_events_as_list(
|
|
list(self.room_allowed_event_ids) + list(self.room_blocked_event_ids)
|
|
)
|
|
)
|
|
# (room_id, content.body, prev_events) -> event ID
|
|
event_lookup: dict[tuple[str, str, tuple[str, ...]], str] = {}
|
|
for event in events:
|
|
key = (
|
|
event.room_id,
|
|
event.content["body"],
|
|
tuple(event.prev_event_ids()),
|
|
)
|
|
event_lookup[key] = event.event_id
|
|
|
|
returned_event_ids: set[str] = set()
|
|
for pdu in json_body["events"]:
|
|
key = (
|
|
pdu.get("room_id"),
|
|
pdu.get("content", {}).get("body"),
|
|
tuple(pdu.get("prev_events", [])),
|
|
)
|
|
event_id = event_lookup.get(key)
|
|
if event_id is None:
|
|
# Not one of the events we created; ignore it.
|
|
continue
|
|
returned_event_ids.add(event_id)
|
|
return returned_event_ids
|
|
|
|
def test_get_missing_events_returns_events_from_correct_room(self) -> None:
|
|
"""
|
|
Tests the happy path when `latest_events` and `earliest_events`
|
|
are both in the correct room.
|
|
|
|
returned
|
|
|
|
|
v
|
|
e1 <- e2 <- e3 <- e4 <- e5
|
|
^ ^
|
|
| |
|
|
earliest latest
|
|
|
|
Not a regression test; I'm just filling a gap in our (in-repo) testing
|
|
as far as I can tell.
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
"earliest_events": [self.room_allowed_event_ids[1]],
|
|
"latest_events": [self.room_allowed_event_ids[3]],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
|
|
self.assertEqual(
|
|
self._extract_returned_event_ids(channel.json_body),
|
|
{self.room_allowed_event_ids[2]},
|
|
)
|
|
|
|
def test_get_missing_events_with_empty_earliest_events(self) -> None:
|
|
"""
|
|
Tests that `/get_missing_events`, when given no `earliest_events`,
|
|
walks back to the start of the room, capped at `limit`.
|
|
|
|
(Not a regression test; documents pre-existing behaviour)
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
"earliest_events": [],
|
|
"latest_events": [self.room_allowed_event_ids[-1]],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
|
|
self.assertEqual(
|
|
self._extract_returned_event_ids(channel.json_body),
|
|
set(self.room_allowed_event_ids[:-1]),
|
|
)
|
|
|
|
def test_get_missing_events_with_unknown_earliest_event(self) -> None:
|
|
"""
|
|
Tests that `/get_missing_events` ignores unknown event IDs given in
|
|
`earliest_events`.
|
|
|
|
This makes sense as the `earliest_events` are intuitively
|
|
'events to stop at' when walking backwards.
|
|
Since we don't know about those events, we don't use them as stopping conditions.
|
|
(In other words, this falls back to the same behaviour as
|
|
`test_get_missing_events_with_empty_earliest_events`.)
|
|
|
|
(Not a regression test; documents pre-existing behaviour)
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
"earliest_events": ["$someUnknownEventId"],
|
|
"latest_events": [self.room_allowed_event_ids[-1]],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
|
|
self.assertEqual(
|
|
self._extract_returned_event_ids(channel.json_body),
|
|
set(self.room_allowed_event_ids[:-1]),
|
|
)
|
|
|
|
def test_get_missing_events_with_no_latest_event(self) -> None:
|
|
"""
|
|
Tests that when the `/get_missing_events` request references
|
|
no events in `latest_events`, the response is 200 OK
|
|
with an empty `events` list.
|
|
|
|
(Not a regression test; documents pre-existing behaviour)
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
"earliest_events": ["$someOtherUnknownEventId"],
|
|
"latest_events": [],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
|
|
self.assertEqual(channel.json_body, {"events": []})
|
|
|
|
def test_get_missing_events_with_unknown_latest_event(self) -> None:
|
|
"""
|
|
Tests that when the `/get_missing_events` request references
|
|
unknown events in `latest_events`, the response is 200 OK
|
|
with an empty `events` list.
|
|
|
|
I imagine this makes sense as you might request several events
|
|
in `latest_events` to start walking back from and we need to be
|
|
tolerant of the fact that servers don't always know about every event.
|
|
|
|
(Not a regression test; documents pre-existing behaviour)
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
"earliest_events": ["$someOtherUnknownEventId"],
|
|
"latest_events": ["$someUnknownEventId"],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
|
|
self.assertEqual(channel.json_body, {"events": []})
|
|
|
|
def test_get_missing_events_ignores_events_from_other_room(self) -> None:
|
|
"""
|
|
Tests that providing `earliest_events` and `latest_events` from the wrong room
|
|
treats them the same as being unknown.
|
|
|
|
From `test_get_missing_events_with_unknown_latest_event` we established that
|
|
unknown events in `latest_events` get skipped (to the point of returning an empty
|
|
`events: []` response)
|
|
|
|
From `test_get_missing_events_with_unknown_earliest_event` we established that
|
|
unknown events in `earliest_events` get ignored as stopping conditions.
|
|
|
|
This regression test previously failed.
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
"earliest_events": [self.room_blocked_event_ids[0]],
|
|
"latest_events": [self.room_blocked_event_ids[-1]],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
|
|
self.assertEqual(channel.json_body, {"events": []})
|
|
|
|
def test_get_missing_events_skips_latest_events_from_other_room(self) -> None:
|
|
"""
|
|
Tests that providing `latest_events` from the wrong room
|
|
treats it as being unknown, even if `earliest_events` are from the correct
|
|
room.
|
|
|
|
From `test_get_missing_events_with_unknown_latest_event` we established that
|
|
unknown events in `latest_events` get skipped (to the point of returning an empty
|
|
`events: []` response)
|
|
|
|
This regression test previously failed.
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
"earliest_events": [self.room_allowed_event_ids[0]],
|
|
"latest_events": [self.room_blocked_event_ids[-1]],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
|
|
self.assertEqual(channel.json_body, {"events": []})
|
|
|
|
def test_get_missing_events_ignores_earliest_events_from_other_room(self) -> None:
|
|
"""
|
|
Tests that providing `earliest_events` from the wrong room causes those
|
|
events to be ignored as stopping conditions,
|
|
even though `latest_events` are from the correct room.
|
|
|
|
From `test_get_missing_events_with_unknown_earliest_event` we established that
|
|
unknown events in `earliest_events` get ignored as stopping conditions.
|
|
|
|
This test was previously fine, but is an obvious extra case.
|
|
"""
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_allowed}",
|
|
content={
|
|
# Use [-3] here as we want to see if the walk-back algorithm
|
|
# confuses depth (topological ordering) across the two rooms.
|
|
"earliest_events": [self.room_blocked_event_ids[-3]],
|
|
"latest_events": [self.room_allowed_event_ids[-1]],
|
|
"limit": 10,
|
|
},
|
|
)
|
|
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
|
|
self.assertEqual(
|
|
self._extract_returned_event_ids(channel.json_body),
|
|
set(self.room_allowed_event_ids[:-1]),
|
|
)
|
|
|
|
|
|
def _create_acl_event(content: JsonDict) -> EventBase:
|
|
return make_test_event(
|
|
{
|
|
"room_id": "!a:b",
|
|
"event_id": "$a:b",
|
|
"type": "m.room.server_acls",
|
|
"sender": "@a:b",
|
|
"content": content,
|
|
}
|
|
)
|
|
|
|
|
|
class GetMissingEventsStateDagTests(unittest.FederatingHomeserverTestCase):
|
|
"""
|
|
Tests for walking the MSC4242 state DAG via /get_missing_events.
|
|
|
|
In future, these can be ported to Complement to benefit other servers,
|
|
but as of writing MSC4242 is not fully implemented so can't be in Complement yet.
|
|
"""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
login.register_servlets,
|
|
room.register_servlets,
|
|
]
|
|
|
|
def default_config(self) -> JsonDict:
|
|
config = super().default_config()
|
|
config["experimental_features"] = {"msc4242_enabled": True}
|
|
return config
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
super().prepare(reactor, clock, hs)
|
|
|
|
self.local_user_id = self.register_user("alice", "pass")
|
|
self.local_user_token = self.login("alice", "pass")
|
|
self.room_id = self.helper.create_room_as(
|
|
room_creator=self.local_user_id,
|
|
tok=self.local_user_token,
|
|
room_version=RoomVersions.MSC4242v12.identifier,
|
|
)
|
|
self.inject_room_member(
|
|
self.room_id, f"@remote:{self.OTHER_SERVER_NAME}", "join"
|
|
)
|
|
|
|
# a message is interleaved between the two topics, so it is an ancestor of
|
|
# `second_topic_id` in the timeline DAG but absent from the state DAG
|
|
self.first_topic_id = self.helper.send_state(
|
|
self.room_id, "m.room.topic", {"topic": "one"}, tok=self.local_user_token
|
|
)["event_id"]
|
|
self.message_id = self.helper.send(
|
|
self.room_id, body="a message", tok=self.local_user_token
|
|
)["event_id"]
|
|
self.second_topic_id = self.helper.send_state(
|
|
self.room_id, "m.room.topic", {"topic": "two"}, tok=self.local_user_token
|
|
)["event_id"]
|
|
|
|
def _get_missing_events(
|
|
self,
|
|
earliest_events: list[str],
|
|
latest_events: list[str],
|
|
walk_state_dag: bool,
|
|
limit: int = 10,
|
|
) -> list[JsonDict]:
|
|
content: JsonDict = {
|
|
"earliest_events": earliest_events,
|
|
"latest_events": latest_events,
|
|
"limit": limit,
|
|
}
|
|
if walk_state_dag:
|
|
content[StateDag.GET_MISSING_EVENTS_FIELD] = True
|
|
|
|
channel = self.make_signed_federation_request(
|
|
"POST",
|
|
f"/_matrix/federation/v1/get_missing_events/{self.room_id}",
|
|
content=content,
|
|
)
|
|
self.assertEqual(HTTPStatus.OK, channel.code, channel.json_body)
|
|
return channel.json_body["events"]
|
|
|
|
def test_walks_the_state_dag(self) -> None:
|
|
events = self._get_missing_events(
|
|
earliest_events=[],
|
|
latest_events=[self.second_topic_id],
|
|
limit=20,
|
|
walk_state_dag=True,
|
|
)
|
|
|
|
returned = [(ev["type"], ev.get("state_key")) for ev in events]
|
|
self.assertIncludes(
|
|
set(returned),
|
|
{
|
|
("m.room.create", ""),
|
|
("m.room.join_rules", ""),
|
|
("m.room.history_visibility", ""),
|
|
("m.room.power_levels", ""),
|
|
("m.room.topic", ""),
|
|
("m.room.member", self.local_user_id),
|
|
("m.room.member", f"@remote:{self.OTHER_SERVER_NAME}"),
|
|
},
|
|
exact=True,
|
|
)
|
|
|
|
# the seed itself is not returned, so only the first topic is
|
|
self.assertEqual(len([ev for ev in returned if ev[0] == "m.room.topic"]), 1)
|
|
|
|
# the state DAG contains no message events
|
|
self.assertEqual([ev for ev in returned if ev[0] == "m.room.message"], [])
|
|
|
|
def test_stops_at_earliest_events(self) -> None:
|
|
events = self._get_missing_events(
|
|
earliest_events=[self.first_topic_id],
|
|
latest_events=[self.second_topic_id],
|
|
limit=20,
|
|
walk_state_dag=True,
|
|
)
|
|
self.assertEqual(events, [])
|
|
|
|
def test_honours_limit(self) -> None:
|
|
events = self._get_missing_events(
|
|
earliest_events=[],
|
|
latest_events=[self.second_topic_id],
|
|
limit=2,
|
|
walk_state_dag=True,
|
|
)
|
|
self.assertEqual(len(events), 2)
|
|
|
|
def test_is_opt_in(self) -> None:
|
|
state_dag_events = self._get_missing_events(
|
|
earliest_events=[],
|
|
latest_events=[self.second_topic_id],
|
|
limit=20,
|
|
walk_state_dag=True,
|
|
)
|
|
timeline_events = self._get_missing_events(
|
|
earliest_events=[],
|
|
latest_events=[self.second_topic_id],
|
|
limit=20,
|
|
walk_state_dag=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
[ev for ev in state_dag_events if ev["type"] == "m.room.message"], []
|
|
)
|
|
self.assertNotEqual(
|
|
[ev for ev in timeline_events if ev["type"] == "m.room.message"], []
|
|
)
|
|
|
|
def test_walks_from_a_non_state_event(self) -> None:
|
|
store = self.hs.get_datastores().main
|
|
message = self.get_success(store.get_event(self.message_id))
|
|
assert supports_msc4242_state_dag(message)
|
|
state_dag = self.get_success(
|
|
store.get_state_dag(self.room_id, set(message.prev_state_events))
|
|
)
|
|
|
|
events = self._get_missing_events(
|
|
earliest_events=[],
|
|
latest_events=[self.message_id],
|
|
walk_state_dag=True,
|
|
limit=20,
|
|
)
|
|
|
|
# We get all the same events
|
|
self.assertIncludes(
|
|
{(ev["type"], ev.get("state_key")) for ev in events},
|
|
{(ev.type, ev.state_key) for ev in state_dag.values()},
|
|
exact=True,
|
|
)
|
|
# and no messages (we aren't walking up prev_events)
|
|
self.assertEqual([ev for ev in events if ev["type"] == "m.room.message"], [])
|
|
|
|
def test_non_state_seed_stops_at_earliest_events(self) -> None:
|
|
message = self.get_success(
|
|
self.hs.get_datastores().main.get_event(self.message_id)
|
|
)
|
|
assert supports_msc4242_state_dag(message)
|
|
events = self._get_missing_events(
|
|
earliest_events=list(message.prev_state_events),
|
|
latest_events=[self.message_id],
|
|
walk_state_dag=True,
|
|
limit=20,
|
|
)
|
|
self.assertEqual(events, [])
|
|
|
|
|
|
class MessageAcceptTests(unittest.FederatingHomeserverTestCase):
|
|
"""
|
|
Tests to make sure that we don't accept flawed events from federation (incoming).
|
|
"""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
login.register_servlets,
|
|
room.register_servlets,
|
|
]
|
|
|
|
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
|
self.http_client = Mock()
|
|
return self.setup_test_homeserver(federation_http_client=self.http_client)
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
super().prepare(reactor, clock, hs)
|
|
|
|
self.store = self.hs.get_datastores().main
|
|
self.storage_controllers = hs.get_storage_controllers()
|
|
self.federation_event_handler = self.hs.get_federation_event_handler()
|
|
|
|
# Create a local room
|
|
user1_id = self.register_user("user1", "pass")
|
|
user1_tok = self.login(user1_id, "pass")
|
|
self.room_id = self.helper.create_room_as(
|
|
user1_id, tok=user1_tok, is_public=True
|
|
)
|
|
|
|
state_map = self.get_success(
|
|
self.storage_controllers.state.get_current_state(self.room_id)
|
|
)
|
|
|
|
# Figure out what the forward extremities in the room are (the most recent
|
|
# events that aren't tied into the DAG)
|
|
forward_extremity_event_ids = self.get_success(
|
|
self.hs.get_datastores().main.get_latest_event_ids_in_room(self.room_id)
|
|
)
|
|
|
|
# Join a remote user to the room that will attempt to send bad events
|
|
self.remote_bad_user_id = f"@baduser:{self.OTHER_SERVER_NAME}"
|
|
self.remote_bad_user_join_event = make_test_event(
|
|
self.add_hashes_and_signatures_from_other_server(
|
|
{
|
|
"room_id": self.room_id,
|
|
"sender": self.remote_bad_user_id,
|
|
"state_key": self.remote_bad_user_id,
|
|
"depth": 1000,
|
|
"origin_server_ts": 1,
|
|
"type": EventTypes.Member,
|
|
"content": {"membership": Membership.JOIN},
|
|
"auth_events": [
|
|
state_map[(EventTypes.Create, "")].event_id,
|
|
state_map[(EventTypes.JoinRules, "")].event_id,
|
|
],
|
|
"prev_events": list(forward_extremity_event_ids),
|
|
}
|
|
),
|
|
room_version=RoomVersions.V10,
|
|
)
|
|
|
|
# Send the join, it should return None (which is not an error)
|
|
self.assertEqual(
|
|
self.get_success(
|
|
self.federation_event_handler.on_receive_pdu(
|
|
self.OTHER_SERVER_NAME, self.remote_bad_user_join_event
|
|
)
|
|
),
|
|
None,
|
|
)
|
|
|
|
# Make sure we actually joined the room
|
|
self.assertEqual(
|
|
self.get_success(self.store.get_latest_event_ids_in_room(self.room_id)),
|
|
{self.remote_bad_user_join_event.event_id},
|
|
)
|
|
|
|
def test_cant_hide_direct_ancestors(self) -> None:
|
|
"""
|
|
If you send a message, you must be able to provide the direct
|
|
prev_events that said event references.
|
|
"""
|
|
|
|
async def post_json(
|
|
destination: str,
|
|
path: str,
|
|
data: JsonDict | None = None,
|
|
long_retries: bool = False,
|
|
timeout: int | None = None,
|
|
ignore_backoff: bool = False,
|
|
args: QueryParams | None = None,
|
|
) -> JsonDict | list:
|
|
# If it asks us for new missing events, give them NOTHING
|
|
if path.startswith("/_matrix/federation/v1/get_missing_events/"):
|
|
return {"events": []}
|
|
return {}
|
|
|
|
self.http_client.post_json = post_json
|
|
|
|
# Figure out what the forward extremities in the room are (the most recent
|
|
# events that aren't tied into the DAG)
|
|
forward_extremity_event_ids = self.get_success(
|
|
self.hs.get_datastores().main.get_latest_event_ids_in_room(self.room_id)
|
|
)
|
|
|
|
# Now lie about an event's prev_events
|
|
lying_event = make_test_event(
|
|
self.add_hashes_and_signatures_from_other_server(
|
|
{
|
|
"room_id": self.room_id,
|
|
"sender": self.remote_bad_user_id,
|
|
"depth": 1000,
|
|
"origin_server_ts": 1,
|
|
"type": "m.room.message",
|
|
"content": {"body": "hewwo?"},
|
|
"auth_events": [],
|
|
"prev_events": ["$missing_prev_event"]
|
|
+ list(forward_extremity_event_ids),
|
|
}
|
|
),
|
|
room_version=RoomVersions.V10,
|
|
)
|
|
|
|
with LoggingContext(
|
|
name="test-context",
|
|
server_name=self.hs.hostname,
|
|
):
|
|
failure = self.get_failure(
|
|
self.federation_event_handler.on_receive_pdu(
|
|
self.OTHER_SERVER_NAME, lying_event
|
|
),
|
|
FederationError,
|
|
)
|
|
|
|
# on_receive_pdu should throw an error
|
|
self.assertEqual(
|
|
failure.value.args[0],
|
|
(
|
|
"ERROR 403: Your server isn't divulging details about prev_events "
|
|
"referenced in this event."
|
|
),
|
|
)
|
|
|
|
# Make sure the invalid event isn't there
|
|
extrem = self.get_success(self.store.get_latest_event_ids_in_room(self.room_id))
|
|
self.assertEqual(extrem, {self.remote_bad_user_join_event.event_id})
|
|
|
|
|
|
class ServerACLsTestCase(unittest.TestCase):
|
|
def test_blocked_server(self) -> None:
|
|
e = _create_acl_event({"allow": ["*"], "deny": ["evil.com"]})
|
|
logger.info("ACL event: %s", e.content)
|
|
|
|
server_acl_evalutor = server_acl_evaluator_from_event(e)
|
|
|
|
self.assertFalse(server_acl_evalutor.server_matches_acl_event("evil.com"))
|
|
self.assertFalse(server_acl_evalutor.server_matches_acl_event("EVIL.COM"))
|
|
|
|
self.assertTrue(server_acl_evalutor.server_matches_acl_event("evil.com.au"))
|
|
self.assertTrue(
|
|
server_acl_evalutor.server_matches_acl_event("honestly.not.evil.com")
|
|
)
|
|
|
|
def test_block_ip_literals(self) -> None:
|
|
e = _create_acl_event({"allow_ip_literals": False, "allow": ["*"]})
|
|
logger.info("ACL event: %s", e.content)
|
|
|
|
server_acl_evalutor = server_acl_evaluator_from_event(e)
|
|
|
|
self.assertFalse(server_acl_evalutor.server_matches_acl_event("1.2.3.4"))
|
|
self.assertTrue(server_acl_evalutor.server_matches_acl_event("1a.2.3.4"))
|
|
self.assertFalse(server_acl_evalutor.server_matches_acl_event("[1:2::]"))
|
|
self.assertTrue(server_acl_evalutor.server_matches_acl_event("1:2:3:4"))
|
|
|
|
def test_wildcard_matching(self) -> None:
|
|
e = _create_acl_event({"allow": ["good*.com"]})
|
|
|
|
server_acl_evalutor = server_acl_evaluator_from_event(e)
|
|
|
|
self.assertTrue(
|
|
server_acl_evalutor.server_matches_acl_event("good.com"),
|
|
"* matches 0 characters",
|
|
)
|
|
self.assertTrue(
|
|
server_acl_evalutor.server_matches_acl_event("GOOD.COM"),
|
|
"pattern is case-insensitive",
|
|
)
|
|
self.assertTrue(
|
|
server_acl_evalutor.server_matches_acl_event("good.aa.com"),
|
|
"* matches several characters, including '.'",
|
|
)
|
|
self.assertFalse(
|
|
server_acl_evalutor.server_matches_acl_event("ishgood.com"),
|
|
"pattern does not allow prefixes",
|
|
)
|
|
|
|
|
|
class StateQueryTests(unittest.FederatingHomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def test_needs_to_be_in_room(self) -> None:
|
|
"""/v1/state/<room_id> requires the server to be in the room"""
|
|
u1 = self.register_user("u1", "pass")
|
|
u1_token = self.login("u1", "pass")
|
|
|
|
room_1 = self.helper.create_room_as(u1, tok=u1_token)
|
|
|
|
channel = self.make_signed_federation_request(
|
|
"GET", "/_matrix/federation/v1/state/%s?event_id=xyz" % (room_1,)
|
|
)
|
|
self.assertEqual(HTTPStatus.FORBIDDEN, channel.code, channel.result)
|
|
self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
|
|
|
|
|
|
class TimestampToEventTests(unittest.FederatingHomeserverTestCase):
|
|
"""Tests for `GET /_matrix/federation/v1/timestamp_to_event/<roomID>`."""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
# Create a room and join the remote server so it's allowed to query
|
|
user = self.register_user("u1", "pass")
|
|
tok = self.login("u1", "pass")
|
|
self.room_id = self.helper.create_room_as(user, tok=tok)
|
|
# Send one event at time = 1000s
|
|
self.reactor.advance(1000)
|
|
self.event_at_1000 = self.helper.send_messages(self.room_id, 1, tok=tok)[0]
|
|
|
|
# Send another event at time = 4000s
|
|
self.reactor.advance(3000)
|
|
self.event_at_4000 = self.helper.send_messages(self.room_id, 1, tok=tok)[0]
|
|
|
|
# Send another event at time = 8000s
|
|
self.reactor.advance(4000)
|
|
self.event_at_8000 = self.helper.send_messages(self.room_id, 1, tok=tok)[0]
|
|
|
|
super().prepare(reactor, clock, hs)
|
|
|
|
@parameterized.expand(
|
|
[
|
|
# Query backwards from 5000s, should find the event at 4000s
|
|
(5000000, "b"),
|
|
# Query forwards from 1100s, should find the event at 4000s
|
|
(1100000, "f"),
|
|
]
|
|
)
|
|
def test_happy_path(self, ts: int, dir: str) -> None:
|
|
"""
|
|
Tests that a server in the room gets 200 OK
|
|
with the closest event IDs as requested for a given timestamp,
|
|
in both forward and backward directions.
|
|
"""
|
|
# Join the remote server to the room
|
|
self.inject_room_member(self.room_id, "@user:" + self.OTHER_SERVER_NAME, "join")
|
|
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/timestamp_to_event/{self.room_id}?ts={ts}&dir={dir}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
self.assertEqual(channel.json_body["event_id"], self.event_at_4000)
|
|
|
|
@parameterized.expand(
|
|
[
|
|
# Query backwards at 0s, no events to be found.
|
|
(0, "b"),
|
|
# Query forwards from 8100s, no events to be found.
|
|
(8100000, "f"),
|
|
]
|
|
)
|
|
def test_no_matching_event(self, ts: int, dir: str) -> None:
|
|
"""
|
|
Tests that a 404 / M_NOT_FOUND is returned when no event occurs
|
|
in the requested direction of a timestamp.
|
|
"""
|
|
# Join the remote server to the room
|
|
self.inject_room_member(self.room_id, "@user:" + self.OTHER_SERVER_NAME, "join")
|
|
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/timestamp_to_event/{self.room_id}?ts={ts}&dir={dir}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND, channel.json_body)
|
|
self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
|
|
|
|
def test_requires_server_in_room(self) -> None:
|
|
"""
|
|
Tests that a server not in the room is rejected with 403 / M_FORBIDDEN.
|
|
"""
|
|
# Notably: _don't_ join the remote server to the room
|
|
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/timestamp_to_event/{self.room_id}?ts=2000000&dir=b",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.FORBIDDEN, channel.json_body)
|
|
self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
|
|
|
|
|
|
class UnstableGetExtremitiesTests(unittest.FederatingHomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
super().prepare(reactor, clock, hs)
|
|
self._storage_controllers = hs.get_storage_controllers()
|
|
|
|
def _make_endpoint_path(self, room_id: str) -> str:
|
|
return f"/_matrix/federation/unstable/org.matrix.msc4370/extremities/{room_id}"
|
|
|
|
def _remote_join(self, room_id: str, room_version: str) -> str:
|
|
# Note: other tests ensure the called endpoints in this function return useful
|
|
# and proper data.
|
|
|
|
# make_join first
|
|
joining_user = "@misspiggy:" + self.OTHER_SERVER_NAME
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/make_join/{room_id}/{joining_user}?ver={room_version}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
join_result = channel.json_body
|
|
|
|
# Sign/populate the join
|
|
join_event_dict = join_result["event"]
|
|
self.add_hashes_and_signatures_from_other_server(
|
|
join_event_dict,
|
|
KNOWN_ROOM_VERSIONS[room_version],
|
|
)
|
|
if room_version in ["1", "2"]:
|
|
add_hashes_and_signatures(
|
|
KNOWN_ROOM_VERSIONS[room_version],
|
|
join_event_dict,
|
|
signature_name=self.hs.hostname,
|
|
signing_key=self.hs.signing_key,
|
|
)
|
|
|
|
# Send the join
|
|
channel = self.make_signed_federation_request(
|
|
"PUT",
|
|
f"/_matrix/federation/v2/send_join/{room_id}/x",
|
|
content=join_event_dict,
|
|
)
|
|
|
|
# Check that things went okay so the test doesn't become a total train wreck
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
r = self.get_success(self._storage_controllers.state.get_current_state(room_id))
|
|
self.assertEqual(r[("m.room.member", joining_user)].membership, "join")
|
|
|
|
return r[("m.room.member", joining_user)].event_id
|
|
|
|
def _test_get_extremities_common(self, room_version: str) -> None:
|
|
# Create a room to test with
|
|
creator_user_id = self.register_user("kermit", "test")
|
|
tok = self.login("kermit", "test")
|
|
room_id = self.helper.create_room_as(
|
|
room_creator=creator_user_id,
|
|
tok=tok,
|
|
room_version=room_version,
|
|
extra_content={
|
|
# Public preset uses `shared` history visibility, but makes joins
|
|
# easier in our tests.
|
|
# https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3createroom
|
|
"preset": "public_chat"
|
|
},
|
|
)
|
|
|
|
# At this stage we should fail to get the extremities because we're not joined
|
|
# and therefore can't see the events (`shared` history visibility).
|
|
channel = self.make_signed_federation_request(
|
|
"GET", self._make_endpoint_path(room_id)
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.FORBIDDEN, channel.json_body)
|
|
self.assertEqual(channel.json_body["error"], "Host not in room.")
|
|
self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
|
|
|
|
# Now join the room and try again
|
|
# Note: there should be just one extremity: the join
|
|
join_event_id = self._remote_join(room_id, room_version)
|
|
channel = self.make_signed_federation_request(
|
|
"GET", self._make_endpoint_path(room_id)
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
self.assertEqual(channel.json_body["prev_events"], [join_event_id])
|
|
|
|
# ACL the calling server and try again. This should cause an error getting extremities.
|
|
self.helper.send_state(
|
|
room_id,
|
|
"m.room.server_acl",
|
|
{
|
|
"allow": ["*"],
|
|
"allow_ip_literals": False,
|
|
"deny": [self.OTHER_SERVER_NAME],
|
|
},
|
|
tok=tok,
|
|
expect_code=HTTPStatus.OK,
|
|
)
|
|
channel = self.make_signed_federation_request(
|
|
"GET", self._make_endpoint_path(room_id)
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.FORBIDDEN, channel.json_body)
|
|
self.assertEqual(channel.json_body["error"], "Server is banned from room")
|
|
self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
|
|
|
|
# FIXME: Exclude MSC4242 room versions whilst it lacks federation support
|
|
@parameterized.expand(
|
|
[
|
|
(k,)
|
|
for k in KNOWN_ROOM_VERSIONS.keys()
|
|
if k != RoomVersions.MSC4242v12.identifier
|
|
]
|
|
)
|
|
@override_config(
|
|
{"use_frozen_dicts": True, "experimental_features": {"msc4370_enabled": True}}
|
|
)
|
|
def test_get_extremities_with_frozen_dicts(self, room_version: str) -> None:
|
|
"""Test GET /extremities with USE_FROZEN_DICTS=True"""
|
|
self._test_get_extremities_common(room_version)
|
|
|
|
# FIXME: Exclude MSC4242 room versions whilst it lacks federation support
|
|
@parameterized.expand(
|
|
[
|
|
(k,)
|
|
for k in KNOWN_ROOM_VERSIONS.keys()
|
|
if k != RoomVersions.MSC4242v12.identifier
|
|
]
|
|
)
|
|
@override_config(
|
|
{"use_frozen_dicts": False, "experimental_features": {"msc4370_enabled": True}}
|
|
)
|
|
def test_get_extremities_without_frozen_dicts(self, room_version: str) -> None:
|
|
"""Test GET /extremities with USE_FROZEN_DICTS=False"""
|
|
self._test_get_extremities_common(room_version)
|
|
|
|
# note the lack of config-setting stuff on this test.
|
|
def test_get_extremities_unstable_not_enabled(self) -> None:
|
|
"""Test that GET /extremities returns M_UNRECOGNIZED when MSC4370 is not enabled"""
|
|
# We shouldn't even have to create a room - the endpoint should just fail.
|
|
channel = self.make_signed_federation_request(
|
|
"GET", self._make_endpoint_path("!room:example.org")
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND, channel.json_body)
|
|
self.assertEqual(channel.json_body["errcode"], "M_UNRECOGNIZED")
|
|
|
|
|
|
class EventAuthFederationTests(unittest.FederatingHomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
# Create a local user
|
|
self.user_id = self.register_user("alice", "password")
|
|
self.user_tok = self.login("alice", "password")
|
|
|
|
# Set up a room and join the remote server to it
|
|
self.room_id = self.helper.create_room_as(
|
|
self.user_id,
|
|
is_public=True,
|
|
room_version=RoomVersions.V10.identifier,
|
|
tok=self.user_tok,
|
|
)
|
|
self.inject_room_member(
|
|
self.room_id, f"@remote:{self.OTHER_SERVER_NAME}", Membership.JOIN
|
|
)
|
|
|
|
# Create a known event whose auth chain we can request back.
|
|
self.event_id = self.helper.send_messages(
|
|
self.room_id, num_events=1, tok=self.user_tok
|
|
)[0]
|
|
|
|
return super().prepare(reactor, clock, hs)
|
|
|
|
def test_event_auth_unknown_event_returns_404(self) -> None:
|
|
"""
|
|
Tests that requesting the auth chain of an unknown event
|
|
returns 404 / M_NOT_FOUND.
|
|
"""
|
|
|
|
# Request an event that doesn't exist in self.room_id.
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/event_auth/{self.room_id}/$unknownevent",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND, channel.result)
|
|
self.assertEqual(
|
|
channel.json_body["errcode"], Codes.NOT_FOUND, channel.json_body
|
|
)
|
|
|
|
def test_event_auth_wrong_room_returns_404(self) -> None:
|
|
"""
|
|
Tests that a request whose `room_id` is wrong for the event
|
|
acts the same as though it were an unknown event.
|
|
|
|
Regression test for https://github.com/element-hq/synapse/security/advisories/GHSA-qcjr-46gf-7f4r
|
|
"""
|
|
|
|
# Create a second room with its own event.
|
|
other_room_id = self.helper.create_room_as(
|
|
self.user_id,
|
|
is_public=True,
|
|
room_version=RoomVersions.V10.identifier,
|
|
tok=self.user_tok,
|
|
)
|
|
other_room_event_id = self.helper.send_messages(
|
|
other_room_id, num_events=1, tok=self.user_tok
|
|
)[0]
|
|
|
|
# Request the chain of other_room_id's event, but pretend it's part of the room
|
|
# we are in.
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/event_auth/{self.room_id}/{other_room_event_id}",
|
|
)
|
|
|
|
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND, channel.result)
|
|
self.assertEqual(
|
|
channel.json_body["errcode"], Codes.NOT_FOUND, channel.json_body
|
|
)
|
|
|
|
|
|
class SendJoinFederationTests(unittest.FederatingHomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
super().prepare(reactor, clock, hs)
|
|
|
|
self._storage_controllers = hs.get_storage_controllers()
|
|
|
|
# create the room
|
|
creator_user_id = self.register_user("kermit", "test")
|
|
tok = self.login("kermit", "test")
|
|
self._room_id = self.helper.create_room_as(
|
|
room_creator=creator_user_id, tok=tok
|
|
)
|
|
|
|
# a second member on the orgin HS
|
|
second_member_user_id = self.register_user("fozzie", "bear")
|
|
tok2 = self.login("fozzie", "bear")
|
|
self.helper.join(self._room_id, second_member_user_id, tok=tok2)
|
|
|
|
def _make_join(self, user_id: str) -> JsonDict:
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/make_join/{self._room_id}/{user_id}"
|
|
f"?ver={DEFAULT_ROOM_VERSION}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
return channel.json_body
|
|
|
|
def _test_send_join_common(self, room_version: str) -> None:
|
|
"""happy-path test of send_join"""
|
|
creator_user_id = self.register_user(f"kermit_v{room_version}", "test")
|
|
tok = self.login(f"kermit_v{room_version}", "test")
|
|
room_id = self.helper.create_room_as(
|
|
room_creator=creator_user_id, tok=tok, room_version=room_version
|
|
)
|
|
|
|
# Second member joins
|
|
second_member_user_id = self.register_user(f"fozzie_v{room_version}", "bear")
|
|
tok2 = self.login(f"fozzie_v{room_version}", "bear")
|
|
self.helper.join(room_id, second_member_user_id, tok=tok2)
|
|
|
|
# Make join for remote user
|
|
joining_user = "@misspiggy:" + self.OTHER_SERVER_NAME
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/make_join/{room_id}/{joining_user}?ver={room_version}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
join_result = channel.json_body
|
|
|
|
# Sign and send the join
|
|
join_event_dict = join_result["event"]
|
|
self.add_hashes_and_signatures_from_other_server(
|
|
join_event_dict,
|
|
KNOWN_ROOM_VERSIONS[room_version],
|
|
)
|
|
if room_version in ["1", "2"]:
|
|
add_hashes_and_signatures(
|
|
KNOWN_ROOM_VERSIONS[room_version],
|
|
join_event_dict,
|
|
signature_name=self.hs.hostname,
|
|
signing_key=self.hs.signing_key,
|
|
)
|
|
channel = self.make_signed_federation_request(
|
|
"PUT",
|
|
f"/_matrix/federation/v2/send_join/{room_id}/x",
|
|
content=join_event_dict,
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
|
|
expected_state = [
|
|
("m.room.create", ""),
|
|
("m.room.power_levels", ""),
|
|
("m.room.join_rules", ""),
|
|
("m.room.history_visibility", ""),
|
|
("m.room.member", f"@kermit_v{room_version}:test"),
|
|
("m.room.member", f"@fozzie_v{room_version}:test"),
|
|
# nb: *not* the joining user
|
|
]
|
|
|
|
if KNOWN_ROOM_VERSIONS[room_version].msc4242_state_dags:
|
|
returned_state_dag = [
|
|
(ev["type"], ev["state_key"]) for ev in channel.json_body["state_dag"]
|
|
]
|
|
self.assertIncludes(
|
|
set(returned_state_dag), set(expected_state), exact=True
|
|
)
|
|
self.assertNotIn("state", channel.json_body)
|
|
self.assertNotIn("auth_chain", channel.json_body)
|
|
else:
|
|
# we should get complete room state back
|
|
returned_state = [
|
|
(ev["type"], ev["state_key"]) for ev in channel.json_body["state"]
|
|
]
|
|
self.assertCountEqual(returned_state, expected_state)
|
|
|
|
# also check the auth chain
|
|
returned_auth_chain_events = [
|
|
(ev["type"], ev["state_key"]) for ev in channel.json_body["auth_chain"]
|
|
]
|
|
self.assertCountEqual(
|
|
returned_auth_chain_events,
|
|
[
|
|
("m.room.create", ""),
|
|
("m.room.member", f"@kermit_v{room_version}:test"),
|
|
("m.room.power_levels", ""),
|
|
("m.room.join_rules", ""),
|
|
],
|
|
)
|
|
|
|
# the room should show that the new user is a member
|
|
r = self.get_success(self._storage_controllers.state.get_current_state(room_id))
|
|
self.assertEqual(r[("m.room.member", joining_user)].membership, "join")
|
|
|
|
@parameterized.expand([(k,) for k in KNOWN_ROOM_VERSIONS.keys()])
|
|
@override_config({"use_frozen_dicts": True})
|
|
def test_send_join_with_frozen_dicts(self, room_version: str) -> None:
|
|
"""Test send_join with USE_FROZEN_DICTS=True"""
|
|
self._test_send_join_common(room_version)
|
|
|
|
@parameterized.expand([(k,) for k in KNOWN_ROOM_VERSIONS.keys()])
|
|
@override_config({"use_frozen_dicts": False})
|
|
def test_send_join_without_frozen_dicts(self, room_version: str) -> None:
|
|
"""Test send_join with USE_FROZEN_DICTS=False"""
|
|
self._test_send_join_common(room_version)
|
|
|
|
@override_config({"experimental_features": {"msc4242_enabled": True}})
|
|
def test_send_join_state_dag(self) -> None:
|
|
"""
|
|
KNOWN_ROOM_VERSIONS lacks MSC4242v12 rooms because it is behind an experimental features flag
|
|
so set the flag and do the same test as above.
|
|
|
|
FIXME: When MSC4242 rooms are not gated behind a
|
|
config flag this test can be deleted.
|
|
"""
|
|
self._test_send_join_common(RoomVersions.MSC4242v12.identifier)
|
|
|
|
@override_config({"experimental_features": {"msc4242_enabled": True}})
|
|
def test_send_join_state_dag_ignores_partial_state(self) -> None:
|
|
# FIXME: when we support partial joins this test can be deleted
|
|
room_version = RoomVersions.MSC4242v12.identifier
|
|
creator_user_id = self.register_user("user1_msc4242", "test")
|
|
tok = self.login(creator_user_id, "test")
|
|
room_id = self.helper.create_room_as(
|
|
room_creator=creator_user_id, tok=tok, room_version=room_version
|
|
)
|
|
|
|
joining_user = "@misspiggy:" + self.OTHER_SERVER_NAME
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/make_join/{room_id}/{joining_user}?ver={room_version}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
|
|
join_event_dict = channel.json_body["event"]
|
|
self.add_hashes_and_signatures_from_other_server(
|
|
join_event_dict,
|
|
KNOWN_ROOM_VERSIONS[room_version],
|
|
)
|
|
# Ask to join as a partial state (omit_members=true) which should be ignored
|
|
# because we don't support partial joins in state DAG rooms just yet
|
|
channel = self.make_signed_federation_request(
|
|
"PUT",
|
|
f"/_matrix/federation/v2/send_join/{room_id}/x?omit_members=true",
|
|
content=join_event_dict,
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
self.assertEqual(channel.json_body["members_omitted"], False)
|
|
self.assertNotIn("servers_in_room", channel.json_body)
|
|
self.assertIn("state_dag", channel.json_body)
|
|
|
|
# the full state DAG is returned, including the member events that a
|
|
# partial state join would have omitted to prove we are in fact ignoring the
|
|
# partial state flag.
|
|
self.assertIncludes(
|
|
{(ev["type"], ev["state_key"]) for ev in channel.json_body["state_dag"]},
|
|
{
|
|
("m.room.create", ""),
|
|
("m.room.power_levels", ""),
|
|
("m.room.join_rules", ""),
|
|
("m.room.history_visibility", ""),
|
|
("m.room.member", creator_user_id),
|
|
},
|
|
exact=True,
|
|
)
|
|
|
|
@override_config({"experimental_features": {"msc4242_enabled": True}})
|
|
def test_make_join_state_dag(self) -> None:
|
|
room_version = RoomVersions.MSC4242v12.identifier
|
|
creator_user_id = self.register_user("user1_msc4242", "test")
|
|
tok = self.login("user1_msc4242", "test")
|
|
room_id = self.helper.create_room_as(
|
|
room_creator=creator_user_id, tok=tok, room_version=room_version
|
|
)
|
|
|
|
joining_user = "@misspiggy:" + self.OTHER_SERVER_NAME
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/make_join/{room_id}/{joining_user}?ver={room_version}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
|
|
event = channel.json_body["event"]
|
|
# MSC4242 events don't have an auth_events field.
|
|
self.assertNotIn("auth_events", event)
|
|
|
|
extremities = self.get_success(
|
|
self.hs.get_datastores().main.get_state_dag_extremities(room_id)
|
|
)
|
|
self.assertGreater(len(extremities), 0)
|
|
# When creating events, prev_state_events should be set to the state DAG fwd extremities
|
|
self.assertIncludes(
|
|
set(event["prev_state_events"]), set(extremities), exact=True
|
|
)
|
|
|
|
def test_send_join_partial_state(self) -> None:
|
|
"""/send_join should return partial state, if requested"""
|
|
joining_user = "@misspiggy:" + self.OTHER_SERVER_NAME
|
|
join_result = self._make_join(joining_user)
|
|
|
|
join_event_dict = join_result["event"]
|
|
self.add_hashes_and_signatures_from_other_server(
|
|
join_event_dict,
|
|
KNOWN_ROOM_VERSIONS[DEFAULT_ROOM_VERSION],
|
|
)
|
|
channel = self.make_signed_federation_request(
|
|
"PUT",
|
|
f"/_matrix/federation/v2/send_join/{self._room_id}/x?omit_members=true",
|
|
content=join_event_dict,
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
|
|
|
# expect a reduced room state
|
|
returned_state = [
|
|
(ev["type"], ev["state_key"]) for ev in channel.json_body["state"]
|
|
]
|
|
self.assertCountEqual(
|
|
returned_state,
|
|
[
|
|
("m.room.create", ""),
|
|
("m.room.power_levels", ""),
|
|
("m.room.join_rules", ""),
|
|
("m.room.history_visibility", ""),
|
|
# Users included here because they're heroes.
|
|
("m.room.member", "@kermit:test"),
|
|
("m.room.member", "@fozzie:test"),
|
|
],
|
|
)
|
|
|
|
# the auth chain should not include anything already in "state"
|
|
returned_auth_chain_events = [
|
|
(ev["type"], ev["state_key"]) for ev in channel.json_body["auth_chain"]
|
|
]
|
|
self.assertCountEqual(
|
|
returned_auth_chain_events,
|
|
# TODO: change the test so that we get at least one event in the auth chain
|
|
# here.
|
|
[],
|
|
)
|
|
|
|
# the room should show that the new user is a member
|
|
r = self.get_success(
|
|
self._storage_controllers.state.get_current_state(self._room_id)
|
|
)
|
|
self.assertEqual(r[("m.room.member", joining_user)].membership, "join")
|
|
|
|
@override_config({"rc_joins_per_room": {"per_second": 0.1, "burst_count": 3}})
|
|
def test_make_join_respects_room_join_rate_limit(self) -> None:
|
|
# In the test setup, two users join the room. Since the rate limiter burst
|
|
# count is 3, a new make_join request to the room should be accepted.
|
|
|
|
joining_user = "@ronniecorbett:" + self.OTHER_SERVER_NAME
|
|
self._make_join(joining_user)
|
|
|
|
# Now have a new local user join the room. This saturates the rate limiter
|
|
# bucket, so the next make_join should be denied.
|
|
new_local_user = self.register_user("animal", "animal")
|
|
token = self.login("animal", "animal")
|
|
self.helper.join(self._room_id, new_local_user, tok=token)
|
|
|
|
joining_user = "@ronniebarker:" + self.OTHER_SERVER_NAME
|
|
channel = self.make_signed_federation_request(
|
|
"GET",
|
|
f"/_matrix/federation/v1/make_join/{self._room_id}/{joining_user}"
|
|
f"?ver={DEFAULT_ROOM_VERSION}",
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.TOO_MANY_REQUESTS, channel.json_body)
|
|
|
|
@override_config({"rc_joins_per_room": {"per_second": 0.1, "burst_count": 3}})
|
|
def test_send_join_contributes_to_room_join_rate_limit_and_is_limited(self) -> None:
|
|
# Make two make_join requests up front. (These are rate limited, but do not
|
|
# contribute to the rate limit.)
|
|
join_event_dicts = []
|
|
for i in range(2):
|
|
joining_user = f"@misspiggy{i}:{self.OTHER_SERVER_NAME}"
|
|
join_result = self._make_join(joining_user)
|
|
join_event_dict = join_result["event"]
|
|
self.add_hashes_and_signatures_from_other_server(
|
|
join_event_dict,
|
|
KNOWN_ROOM_VERSIONS[DEFAULT_ROOM_VERSION],
|
|
)
|
|
join_event_dicts.append(join_event_dict)
|
|
|
|
# In the test setup, two users join the room. Since the rate limiter burst
|
|
# count is 3, the first send_join should be accepted...
|
|
channel = self.make_signed_federation_request(
|
|
"PUT",
|
|
f"/_matrix/federation/v2/send_join/{self._room_id}/join0",
|
|
content=join_event_dicts[0],
|
|
)
|
|
self.assertEqual(channel.code, 200, channel.json_body)
|
|
|
|
# ... but the second should be denied.
|
|
channel = self.make_signed_federation_request(
|
|
"PUT",
|
|
f"/_matrix/federation/v2/send_join/{self._room_id}/join1",
|
|
content=join_event_dicts[1],
|
|
)
|
|
self.assertEqual(channel.code, HTTPStatus.TOO_MANY_REQUESTS, channel.json_body)
|
|
|
|
# NB: we could write a test which checks that the send_join event is seen
|
|
# by other workers over replication, and that they update their rate limit
|
|
# buckets accordingly. I'm going to assume that the join event gets sent over
|
|
# replication, at which point the tests.handlers.room_member test
|
|
# test_local_users_joining_on_another_worker_contribute_to_rate_limit
|
|
# is probably sufficient to reassure that the bucket is updated.
|
|
|
|
|
|
class StripUnsignedFromEventsTestCase(unittest.TestCase):
|
|
"""
|
|
Test to make sure that we handle the raw JSON events from federation carefully and
|
|
strip anything that shouldn't be there.
|
|
"""
|
|
|
|
def test_strip_unauthorized_unsigned_values(self) -> None:
|
|
event1 = {
|
|
"sender": "@baduser:test.serv",
|
|
"state_key": "@baduser:test.serv",
|
|
"event_id": "$event1:test.serv",
|
|
"depth": 1000,
|
|
"origin_server_ts": 1,
|
|
"type": "m.room.member",
|
|
"content": {"membership": "join"},
|
|
"auth_events": [],
|
|
"unsigned": {"malicious garbage": "hackz", "more warez": "more hackz"},
|
|
}
|
|
filtered_event = make_test_pdu_event(event1, RoomVersions.V1)
|
|
# Make sure unauthorized fields are stripped from unsigned
|
|
self.assertNotIn("more warez", filtered_event.unsigned)
|
|
|
|
def test_strip_event_maintains_allowed_fields(self) -> None:
|
|
event2 = {
|
|
"sender": "@baduser:test.serv",
|
|
"state_key": "@baduser:test.serv",
|
|
"event_id": "$event2:test.serv",
|
|
"depth": 1000,
|
|
"origin_server_ts": 1,
|
|
"type": "m.room.member",
|
|
"auth_events": [],
|
|
"content": {"membership": "join"},
|
|
"unsigned": {
|
|
"malicious garbage": "hackz",
|
|
"more warez": "more hackz",
|
|
"age": 14,
|
|
"invite_room_state": [],
|
|
},
|
|
}
|
|
|
|
filtered_event2 = make_test_pdu_event(event2, RoomVersions.V1, received_time=20)
|
|
self.assertIn("age_ts", filtered_event2.unsigned)
|
|
self.assertEqual(6, filtered_event2.unsigned["age_ts"])
|
|
self.assertNotIn("more warez", filtered_event2.unsigned)
|
|
# Invite_room_state is allowed in events of type m.room.member
|
|
self.assertIn("invite_room_state", filtered_event2.unsigned)
|
|
self.assertEqual([], filtered_event2.unsigned["invite_room_state"])
|
|
|
|
def test_strip_event_removes_fields_based_on_event_type(self) -> None:
|
|
event3 = {
|
|
"sender": "@baduser:test.serv",
|
|
"state_key": "@baduser:test.serv",
|
|
"event_id": "$event3:test.serv",
|
|
"depth": 1000,
|
|
"origin_server_ts": 1,
|
|
"type": "m.room.power_levels",
|
|
"content": {},
|
|
"auth_events": [],
|
|
"unsigned": {
|
|
"malicious garbage": "hackz",
|
|
"more warez": "more hackz",
|
|
"age": 14,
|
|
"invite_room_state": [],
|
|
},
|
|
}
|
|
filtered_event3 = make_test_pdu_event(event3, RoomVersions.V1, received_time=20)
|
|
self.assertIn("age_ts", filtered_event3.unsigned)
|
|
# Invite_room_state field is only permitted in event type m.room.member
|
|
self.assertNotIn("invite_room_state", filtered_event3.unsigned)
|
|
self.assertNotIn("more warez", filtered_event3.unsigned)
|