summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--changelog.d/5576.bugfix1
-rw-r--r--changelog.d/5610.feature1
-rw-r--r--synapse/handlers/room_member.py1
-rw-r--r--synapse/third_party_rules/access_rules.py154
-rw-r--r--tests/handlers/test_identity.py108
-rw-r--r--tests/rest/client/test_room_access_rules.py56
6 files changed, 283 insertions, 38 deletions
diff --git a/changelog.d/5576.bugfix b/changelog.d/5576.bugfix
new file mode 100644
index 0000000000..c1ba5581f2
--- /dev/null
+++ b/changelog.d/5576.bugfix
@@ -0,0 +1 @@
+Fix a bug that would cause invited users to receive several emails for a single 3PID invite in case the inviter is rate limited.
diff --git a/changelog.d/5610.feature b/changelog.d/5610.feature
new file mode 100644
index 0000000000..b99514f97e
--- /dev/null
+++ b/changelog.d/5610.feature
@@ -0,0 +1 @@
+Implement new custom event rules for power levels.
diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py
index 1517907898..e940e4183b 100644
--- a/synapse/handlers/room_member.py
+++ b/synapse/handlers/room_member.py
@@ -935,6 +935,7 @@ class RoomMemberHandler(object):
                 "sender": user.to_string(),
                 "state_key": token,
             },
+            ratelimit=False,
             txn_id=txn_id,
         )
 
diff --git a/synapse/third_party_rules/access_rules.py b/synapse/third_party_rules/access_rules.py
index d13f8de888..c8698c66cc 100644
--- a/synapse/third_party_rules/access_rules.py
+++ b/synapse/third_party_rules/access_rules.py
@@ -33,6 +33,21 @@ VALID_ACCESS_RULES = (
     ACCESS_RULE_UNRESTRICTED,
 )
 
+# Rules to which we need to apply the power levels restrictions.
+#
+# These are all of the rules that neither:
+#  * forbid users from joining based on a server blacklist (which means that there
+#     is no need to apply power level restrictions), nor
+#  * target direct chats (since we allow both users to be room admins in this case).
+#
+# The power-level restrictions, when they are applied, prevent the following:
+#  * the default power level for users (users_default) being set to anything other than 0.
+#  * a non-default power level being assigned to any user which would be forbidden from
+#     joining a restricted room.
+RULES_WITH_RESTRICTED_POWER_LEVELS = (
+    ACCESS_RULE_UNRESTRICTED,
+)
+
 
 class RoomAccessRules(object):
     """Implementation of the ThirdPartyEventRules module API that allows federation admins
@@ -79,34 +94,32 @@ class RoomAccessRules(object):
         default rule to the initial state.
         """
         is_direct = config.get("is_direct")
-        rules_in_initial_state = False
+        rule = None
 
         # If there's a rules event in the initial state, check if it complies with the
         # spec for im.vector.room.access_rules and deny the request if not.
         for event in config.get("initial_state", []):
             if event["type"] == ACCESS_RULES_TYPE:
-                rules_in_initial_state = True
-
                 rule = event["content"].get("rule")
 
                 # Make sure the event has a valid content.
                 if rule is None:
-                    raise SynapseError(400, "Invalid access rule",)
+                    raise SynapseError(400, "Invalid access rule")
 
                 # Make sure the rule name is valid.
                 if rule not in VALID_ACCESS_RULES:
-                    raise SynapseError(400, "Invalid access rule", )
+                    raise SynapseError(400, "Invalid access rule")
 
                 # Make sure the rule is "direct" if the room is a direct chat.
                 if (
                     (is_direct and rule != ACCESS_RULE_DIRECT)
                     or (rule == ACCESS_RULE_DIRECT and not is_direct)
                 ):
-                    raise SynapseError(400, "Invalid access rule",)
+                    raise SynapseError(400, "Invalid access rule")
 
         # If there's no rules event in the initial state, create one with the default
         # setting.
-        if not rules_in_initial_state:
+        if not rule:
             if is_direct:
                 default_rule = ACCESS_RULE_DIRECT
             else:
@@ -123,6 +136,22 @@ class RoomAccessRules(object):
                 }
             })
 
+            rule = default_rule
+
+        # Check if the creator can override values for the power levels.
+        allowed = self._is_power_level_content_allowed(
+            config.get("power_level_content_override", {}), rule,
+        )
+        if not allowed:
+            raise SynapseError(400, "Invalid power levels content override")
+
+        # Second loop for events we need to know the current rule to process.
+        for event in config.get("initial_state", []):
+            if event["type"] == EventTypes.PowerLevels:
+                allowed = self._is_power_level_content_allowed(event["content"], rule)
+                if not allowed:
+                    raise SynapseError(400, "Invalid power levels content")
+
     @defer.inlineCallbacks
     def check_threepid_can_be_invited(self, medium, address, state_events):
         """Implements synapse.events.ThirdPartyEventRules.check_threepid_can_be_invited
@@ -172,24 +201,19 @@ class RoomAccessRules(object):
         Checks the event's type and the current rule and calls the right function to
         determine whether the event can be allowed.
         """
-        # Special-case the access rules event.
         if event.type == ACCESS_RULES_TYPE:
             return self._on_rules_change(event, state_events)
 
+        # We need to know the rule to apply when processing the event types below.
         rule = self._get_rule_from_state(state_events)
 
-        if rule == ACCESS_RULE_RESTRICTED:
-            ret = self._apply_restricted(event)
-        elif rule == ACCESS_RULE_UNRESTRICTED:
-            ret = self._apply_unrestricted()
-        elif rule == ACCESS_RULE_DIRECT:
-            ret = self._apply_direct(event, state_events)
-        else:
-            # We currently apply the default (restricted) if we don't know the rule, we
-            # might want to change that in the future.
-            ret = self._apply_restricted(event)
+        if event.type == EventTypes.PowerLevels:
+            return self._is_power_level_content_allowed(event.content, rule)
 
-        return ret
+        if event.type == EventTypes.Member or event.type == EventTypes.ThirdPartyInvite:
+            return self._on_membership_or_invite(event, rule, state_events)
+
+        return True
 
     def _on_rules_change(self, event, state_events):
         """Implement the checks and behaviour specified on allowing or forbidding a new
@@ -232,35 +256,67 @@ class RoomAccessRules(object):
 
         return False
 
-    def _apply_restricted(self, event):
+    def _on_membership_or_invite(self, event, rule, state_events):
+        """Applies the correct rule for incoming m.room.member and
+        m.room.third_party_invite events.
+
+        Args:
+            event (synapse.events.EventBase): The event to check.
+            rule (str): The name of the rule to apply.
+            state_events (dict[tuple[event type, state key], EventBase]): The state of the
+                room before the event was sent.
+        Returns:
+            bool, True if the event can be allowed, False otherwise.
+        """
+        if rule == ACCESS_RULE_RESTRICTED:
+            ret = self._on_membership_or_invite_restricted(event)
+        elif rule == ACCESS_RULE_UNRESTRICTED:
+            ret = self._on_membership_or_invite_unrestricted()
+        elif rule == ACCESS_RULE_DIRECT:
+            ret = self._on_membership_or_invite_direct(event, state_events)
+        else:
+            # We currently apply the default (restricted) if we don't know the rule, we
+            # might want to change that in the future.
+            ret = self._on_membership_or_invite_restricted(event)
+
+        return ret
+
+    def _on_membership_or_invite_restricted(self, event):
         """Implements the checks and behaviour specified for the "restricted" rule.
 
+        "restricted" currently means that users can only invite users if their server is
+        included in a limited list of domains.
+
         Args:
             event (synapse.events.EventBase): The event to check.
         Returns:
             bool, True if the event can be allowed, False otherwise.
         """
-        # "restricted" currently means that users can only invite users if their server is
-        # included in a limited list of domains.
-        # We're not filtering on m.room.third_party_member events here because the
-        # filtering on threepids is done in check_threepid_can_be_invited.
-        if event.type != EventTypes.Member:
+        # We're not applying the rules on m.room.third_party_member events here because
+        # the filtering on threepids is done in check_threepid_can_be_invited, which is
+        # called before check_event_allowed.
+        if event.type == EventTypes.ThirdPartyInvite:
             return True
         invitee_domain = get_domain_from_id(event.state_key)
         return invitee_domain not in self.domains_forbidden_when_restricted
 
-    def _apply_unrestricted(self):
+    def _on_membership_or_invite_unrestricted(self):
         """Implements the checks and behaviour specified for the "unrestricted" rule.
 
+        "unrestricted" currently means that every event is allowed.
+
         Returns:
             bool, True if the event can be allowed, False otherwise.
         """
-        # "unrestricted" currently means that every event is allowed.
         return True
 
-    def _apply_direct(self, event, state_events):
+    def _on_membership_or_invite_direct(self, event, state_events):
         """Implements the checks and behaviour specified for the "direct" rule.
 
+        "direct" currently means that no member is allowed apart from the two initial
+        members the room was created for (i.e. the room's creator and their first
+        invitee).
+
         Args:
             event (synapse.events.EventBase): The event to check.
             state_events (dict[tuple[event type, state key], EventBase]): The state of the
@@ -268,12 +324,6 @@ class RoomAccessRules(object):
         Returns:
             bool, True if the event can be allowed, False otherwise.
         """
-        # "direct" currently means that no member is allowed apart from the two initial
-        # members the room was created for (i.e. the room's creator and their first
-        # invitee).
-        if event.type != EventTypes.Member and event.type != EventTypes.ThirdPartyInvite:
-            return True
-
         # Get the room memberships and 3PID invite tokens from the room's state.
         existing_members, threepid_tokens = self._get_members_and_tokens_from_state(
             state_events,
@@ -319,6 +369,42 @@ class RoomAccessRules(object):
 
         return True
 
+    def _is_power_level_content_allowed(self, content, access_rule):
+        """Check if a given power levels event is permitted under the given access rule.
+
+        It shouldn't be allowed if it either changes the default PL to a non-0 value or
+        gives a non-0 PL to a user that would have been forbidden from joining the room
+        under a more restrictive access rule.
+
+        Args:
+            content (dict[]): The content of the m.room.power_levels event to check.
+            access_rule (str): The access rule in place in this room.
+        Returns:
+            bool, True if the event can be allowed, False otherwise.
+
+        """
+        # Check if we need to apply the restrictions with the current rule.
+        if access_rule not in RULES_WITH_RESTRICTED_POWER_LEVELS:
+            return True
+
+        # If users_default is explicitly set to a non-0 value, deny the event.
+        users_default = content.get('users_default', 0)
+        if users_default:
+            return False
+
+        users = content.get('users', {})
+        for user_id, power_level in users.items():
+            server_name = get_domain_from_id(user_id)
+            # Check the domain against the blacklist. If found, and the PL isn't 0, deny
+            # the event.
+            if (
+                server_name in self.domains_forbidden_when_restricted
+                and power_level != 0
+            ):
+                return False
+
+        return True
+
     @staticmethod
     def _get_rule_from_state(state_events):
         """Extract the rule to be applied from the given set of state events.
diff --git a/tests/handlers/test_identity.py b/tests/handlers/test_identity.py
new file mode 100644
index 0000000000..99ce94db52
--- /dev/null
+++ b/tests/handlers/test_identity.py
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 New Vector Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from mock import Mock
+
+from twisted.internet import defer
+
+import synapse.rest.admin
+from synapse.rest.client.v1 import login
+from synapse.rest.client.v2_alpha import account
+
+from tests import unittest
+
+
+class ThreepidISRewrittenURLTestCase(unittest.HomeserverTestCase):
+
+    servlets = [
+        synapse.rest.admin.register_servlets_for_client_rest_resource,
+        login.register_servlets,
+        account.register_servlets,
+    ]
+
+    def make_homeserver(self, reactor, clock):
+        self.address = "test@test"
+        self.is_server_name = "testis"
+        self.rewritten_is_url = "int.testis"
+
+        config = self.default_config()
+        config["trusted_third_party_id_servers"] = [
+            self.is_server_name,
+        ]
+        config["rewrite_identity_server_urls"] = {
+            self.is_server_name: self.rewritten_is_url,
+        }
+
+        mock_http_client = Mock(spec=[
+            "post_urlencoded_get_json",
+        ])
+        mock_http_client.post_urlencoded_get_json.return_value = defer.succeed({
+            "address": self.address,
+            "medium": "email",
+        })
+
+        self.hs = self.setup_test_homeserver(
+            config=config,
+            simple_http_client=mock_http_client,
+        )
+
+        return self.hs
+
+    def prepare(self, reactor, clock, hs):
+        self.user_id = self.register_user("kermit", "monkey")
+
+    def test_rewritten_id_server(self):
+        """
+        Tests that, when validating a 3PID association while rewriting the IS's server
+        name:
+        * the bind request is done against the rewritten hostname
+        * the original, non-rewritten, server name is stored in the database
+        """
+        handler = self.hs.get_handlers().identity_handler
+        post_urlenc_get_json = self.hs.get_simple_http_client().post_urlencoded_get_json
+        store = self.hs.get_datastore()
+
+        creds = {
+            "sid": "123",
+            "client_secret": "some_secret",
+        }
+
+        # Make sure processing the mocked response goes through.
+        data = self.get_success(handler.bind_threepid(
+            {
+                "id_server": self.is_server_name,
+                "client_secret": creds["client_secret"],
+                "sid": creds["sid"],
+            },
+            self.user_id,
+        ))
+        self.assertEqual(data.get("address"), self.address)
+
+        # Check that the request was done against the rewritten server name.
+        post_urlenc_get_json.assert_called_once_with(
+            "https://%s/_matrix/identity/api/v1/3pid/bind" % self.rewritten_is_url,
+            {
+                'sid': creds['sid'],
+                'client_secret': creds["client_secret"],
+                'mxid': self.user_id,
+            }
+        )
+
+        # Check that the original server name is saved in the database instead of the
+        # rewritten one.
+        id_servers = self.get_success(store.get_id_servers_user_bound(
+            self.user_id, "email", self.address
+        ))
+        self.assertEqual(id_servers, [self.is_server_name])
diff --git a/tests/rest/client/test_room_access_rules.py b/tests/rest/client/test_room_access_rules.py
index 5c46dabf32..df48a89e93 100644
--- a/tests/rest/client/test_room_access_rules.py
+++ b/tests/rest/client/test_room_access_rules.py
@@ -22,6 +22,7 @@ from mock import Mock
 
 from twisted.internet import defer
 
+from synapse.api.constants import EventTypes
 from synapse.rest import admin
 from synapse.rest.client.v1 import login, room
 from synapse.third_party_rules.access_rules import (
@@ -147,12 +148,11 @@ class RoomAccessTestCase(unittest.HomeserverTestCase):
         self.assertEqual(rule, ACCESS_RULE_UNRESTRICTED)
 
     def test_create_room_invalid_rule(self):
-        """Tests that creating a room with an invalid rule will set the default value."""
+        """Tests that creating a room with an invalid rule will set fail."""
         self.create_room(rule=ACCESS_RULE_DIRECT, expected_code=400)
 
     def test_create_room_direct_invalid_rule(self):
-        """Tests that creating a direct room with an invalid rule will set the default
-        value.
+        """Tests that creating a direct room with an invalid rule will fail.
         """
         self.create_room(direct=True, rule=ACCESS_RULE_RESTRICTED, expected_code=400)
 
@@ -277,7 +277,9 @@ class RoomAccessTestCase(unittest.HomeserverTestCase):
         )
 
     def test_unrestricted(self):
-        """Tests that, in unrestricted mode, we can invite whoever we want.
+        """Tests that, in unrestricted mode, we can invite whoever we want, but we can
+        only change the power level of users that wouldn't be forbidden in restricted
+        mode.
         """
         # We can invite
         self.helper.invite(
@@ -311,6 +313,52 @@ class RoomAccessTestCase(unittest.HomeserverTestCase):
             expected_code=200,
         )
 
+        # We can send a power level event that doesn't redefine the default PL or set a
+        # non-default PL for a user that would be forbidden in restricted mode.
+        self.helper.send_state(
+            room_id=self.unrestricted_room,
+            event_type=EventTypes.PowerLevels,
+            body={
+                "users": {
+                    self.user_id: 100,
+                    "@test:not_forbidden_domain": 10,
+                },
+            },
+            tok=self.tok,
+            expect_code=200,
+        )
+
+        # We can't send a power level event that redefines the default PL and doesn't set
+        # a non-default PL for a user that would be forbidden in restricted mode.
+        self.helper.send_state(
+            room_id=self.unrestricted_room,
+            event_type=EventTypes.PowerLevels,
+            body={
+                "users": {
+                    self.user_id: 100,
+                    "@test:not_forbidden_domain": 10,
+                },
+                "users_default": 10,
+            },
+            tok=self.tok,
+            expect_code=403,
+        )
+
+        # We can't send a power level event that doesn't redefines the default PL but sets
+        # a non-default PL for a user that would be forbidden in restricted mode.
+        self.helper.send_state(
+            room_id=self.unrestricted_room,
+            event_type=EventTypes.PowerLevels,
+            body={
+                "users": {
+                    self.user_id: 100,
+                    "@test:forbidden_domain": 10,
+                },
+            },
+            tok=self.tok,
+            expect_code=403,
+        )
+
     def test_change_rules(self):
         """Tests that we can only change the current rule from restricted to
         unrestricted.