Merge pull request #5484 from matrix-org/babolivier/dinsic_access_rules

Implement custom access rules
pull/5542/head
Brendan Abolivier 2019-06-19 10:41:37 +01:00 committed by GitHub
commit fa4efb5967
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 812 additions and 0 deletions

View File

@ -0,0 +1,14 @@
# -*- 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.

View File

@ -0,0 +1,372 @@
# -*- 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.
import email.utils
from twisted.internet import defer
from synapse.api.constants import EventTypes
from synapse.api.errors import SynapseError
from synapse.config._base import ConfigError
from synapse.types import get_domain_from_id
ACCESS_RULES_TYPE = "im.vector.room.access_rules"
ACCESS_RULE_RESTRICTED = "restricted"
ACCESS_RULE_UNRESTRICTED = "unrestricted"
ACCESS_RULE_DIRECT = "direct"
VALID_ACCESS_RULES = (
ACCESS_RULE_DIRECT,
ACCESS_RULE_RESTRICTED,
ACCESS_RULE_UNRESTRICTED,
)
class RoomAccessRules(object):
"""Implementation of the ThirdPartyEventRules module API that allows federation admins
to define custom rules for specific events and actions.
Implements the custom behaviour for the "im.vector.room.access_rules" state event.
Takes a config in the format:
third_party_event_rules:
module: third_party_rules.RoomAccessRules
config:
# List of domains (server names) that can't be invited to rooms if the
# "restricted" rule is set. Defaults to an empty list.
domains_forbidden_when_restricted: []
# Identity server to use when checking the HS an email address belongs to
# using the /info endpoint. Required.
id_server: "vector.im"
Don't forget to consider if you can invite users from your own domain.
"""
def __init__(self, config, http_client):
self.http_client = http_client
self.id_server = config["id_server"]
self.domains_forbidden_when_restricted = config.get(
"domains_forbidden_when_restricted", [],
)
@staticmethod
def parse_config(config):
if "id_server" in config:
return config
else:
raise ConfigError("No IS for event rules TchapEventRules")
def on_create_room(self, requester, config, is_requester_admin):
"""Implements synapse.events.ThirdPartyEventRules.on_create_room
Checks if a im.vector.room.access_rules event is being set during room creation.
If yes, make sure the event is correct. Otherwise, append an event with the
default rule to the initial state.
"""
is_direct = config.get("is_direct")
rules_in_initial_state = False
# 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",)
# Make sure the rule name is valid.
if rule not in VALID_ACCESS_RULES:
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",)
# If there's no rules event in the initial state, create one with the default
# setting.
if not rules_in_initial_state:
if is_direct:
default_rule = ACCESS_RULE_DIRECT
else:
default_rule = ACCESS_RULE_RESTRICTED
if not config.get("initial_state"):
config["initial_state"] = []
config["initial_state"].append({
"type": ACCESS_RULES_TYPE,
"state_key": "",
"content": {
"rule": default_rule,
}
})
@defer.inlineCallbacks
def check_threepid_can_be_invited(self, medium, address, state_events):
"""Implements synapse.events.ThirdPartyEventRules.check_threepid_can_be_invited
Check if a threepid can be invited to the room via a 3PID invite given the current
rules and the threepid's address, by retrieving the HS it's mapped to from the
configured identity server, and checking if we can invite users from it.
"""
rule = self._get_rule_from_state(state_events)
if medium != "email":
defer.returnValue(False)
if rule != ACCESS_RULE_RESTRICTED:
# Only "restricted" requires filtering 3PID invites. We don't need to do
# anything for "direct" here, because only "restricted" requires filtering
# based on the HS the address is mapped to.
defer.returnValue(True)
parsed_address = email.utils.parseaddr(address)[1]
if parsed_address != address:
# Avoid reproducing the security issue described here:
# https://matrix.org/blog/2019/04/18/security-update-sydent-1-0-2
# It's probably not worth it but let's just be overly safe here.
defer.returnValue(False)
# Get the HS this address belongs to from the identity server.
res = yield self.http_client.get_json(
"https://%s/_matrix/identity/api/v1/info" % (self.id_server,),
{
"medium": medium,
"address": address,
}
)
# Look for a domain that's not forbidden from being invited.
if not res.get("hs"):
defer.returnValue(False)
if res.get("hs") in self.domains_forbidden_when_restricted:
defer.returnValue(False)
defer.returnValue(True)
def check_event_allowed(self, event, state_events):
"""Implements synapse.events.ThirdPartyEventRules.check_event_allowed
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)
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)
return ret
def _on_rules_change(self, event, state_events):
"""Implement the checks and behaviour specified on allowing or forbidding a new
im.vector.room.access_rules event.
Args:
event (synapse.events.EventBase): The event to check.
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.
"""
new_rule = event.content.get("rule")
# Check for invalid values.
if new_rule not in VALID_ACCESS_RULES:
return False
# Make sure we don't apply "direct" if the room has more than two members.
if new_rule == ACCESS_RULE_DIRECT:
existing_members, threepid_tokens = self._get_members_and_tokens_from_state(
state_events,
)
if len(existing_members) > 2 or len(threepid_tokens) > 1:
return False
prev_rules_event = state_events.get((ACCESS_RULES_TYPE, ""))
# Now that we know the new rule doesn't break the "direct" case, we can allow any
# new rule in rooms that had none before.
if prev_rules_event is None:
return True
prev_rule = prev_rules_event.content.get("rule")
# Currently, we can only go from "restricted" to "unrestricted".
if prev_rule == ACCESS_RULE_RESTRICTED and new_rule == ACCESS_RULE_UNRESTRICTED:
return True
return False
def _apply_restricted(self, event):
"""Implements the checks and behaviour specified for the "restricted" rule.
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:
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):
"""Implements the checks and behaviour specified for the "unrestricted" rule.
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):
"""Implements the checks and behaviour specified for the "direct" rule.
Args:
event (synapse.events.EventBase): The event to check.
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.
"""
# "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,
)
# There should never be more than one 3PID invite in the room state: if the second
# original user came and left, and we're inviting them using their email address,
# given we know they have a Matrix account binded to the address (so they could
# join the first time), Synapse will successfully look it up before attempting to
# store an invite on the IS.
if len(threepid_tokens) == 1 and event.type == EventTypes.ThirdPartyInvite:
# If we already have a 3PID invite in flight, don't accept another one.
return False
if len(existing_members) == 2:
# If the user was within the two initial user of the room, Synapse would have
# looked it up successfully and thus sent a m.room.member here instead of
# m.room.third_party_invite.
if event.type == EventTypes.ThirdPartyInvite:
return False
# We can only have m.room.member events here. The rule in this case is to only
# allow the event if its target is one of the initial two members in the room,
# i.e. the state key of one of the two m.room.member states in the room.
return event.state_key in existing_members
# We're alone in the room (and always have been) and there's one 3PID invite in
# flight.
if len(existing_members) == 1 and len(threepid_tokens) == 1:
# We can only have m.room.member events here. In this case, we can only allow
# the event if it's either a m.room.member from the joined user (we can assume
# that the only m.room.member event is a join otherwise we wouldn't be able to
# send an event to the room) or an an invite event which target is the invited
# user.
target = event.state_key
is_from_threepid_invite = self._is_invite_from_threepid(
event, threepid_tokens[0],
)
if is_from_threepid_invite or target == existing_members[0]:
return True
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.
Args:
state_events (dict[tuple[event type, state key], EventBase]): The set of state
events.
Returns:
str, the name of the rule (either "direct", "restricted" or "unrestricted")
"""
access_rules = state_events.get((ACCESS_RULES_TYPE, ""))
if access_rules is None:
rule = ACCESS_RULE_RESTRICTED
else:
rule = access_rules.content.get("rule")
return rule
@staticmethod
def _get_members_and_tokens_from_state(state_events):
"""Retrieves from a list of state events the list of users that have a
m.room.member event in the room, and the tokens of 3PID invites in the room.
Args:
state_events (dict[tuple[event type, state key], EventBase]): The set of state
events.
Returns:
existing_members (list[str]): List of targets of the m.room.member events in
the state.
threepid_invite_tokens (list[str]): List of tokens of the 3PID invites in the
state.
"""
existing_members = []
threepid_invite_tokens = []
for key, event in state_events.items():
if key[0] == EventTypes.Member:
existing_members.append(event.state_key)
if key[0] == EventTypes.ThirdPartyInvite:
threepid_invite_tokens.append(event.state_key)
return existing_members, threepid_invite_tokens
@staticmethod
def _is_invite_from_threepid(invite, threepid_invite_token):
"""Checks whether the given invite follows the given 3PID invite.
Args:
invite (EventBase): The m.room.member event with "invite" membership.
threepid_invite_token (str): The state key from the 3PID invite.
"""
token = invite.content.get("third_party_signed", {}).get("token", "")
return token == threepid_invite_token

View File

@ -0,0 +1,426 @@
# -*- 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.
import json
import random
import string
from mock import Mock
from twisted.internet import defer
from synapse.rest import admin
from synapse.rest.client.v1 import login, room
from synapse.third_party_rules.access_rules import (
ACCESS_RULE_DIRECT,
ACCESS_RULE_RESTRICTED,
ACCESS_RULE_UNRESTRICTED,
ACCESS_RULES_TYPE,
)
from tests import unittest
class RoomAccessTestCase(unittest.HomeserverTestCase):
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def make_homeserver(self, reactor, clock):
config = self.default_config()
config["third_party_event_rules"] = {
"module": "synapse.third_party_rules.access_rules.RoomAccessRules",
"config": {
"domains_forbidden_when_restricted": [
"forbidden_domain"
],
"id_server": "testis",
}
}
config["trusted_third_party_id_servers"] = [
"testis",
]
def send_invite(destination, room_id, event_id, pdu):
return defer.succeed(pdu)
def get_json(uri, args={}, headers=None):
address_domain = args["address"].split("@")[1]
return defer.succeed({"hs": address_domain})
def post_urlencoded_get_json(uri, args={}, headers=None):
token = ''.join(random.choice(string.ascii_letters) for _ in range(10))
return defer.succeed({
"token": token,
"public_keys": [
{
"public_key": "serverpublickey",
"key_validity_url": "https://testis/pubkey/isvalid",
},
{
"public_key": "phemeralpublickey",
"key_validity_url": "https://testis/pubkey/ephemeral/isvalid",
},
],
"display_name": "f...@b...",
})
mock_federation_client = Mock(spec=[
"send_invite",
])
mock_federation_client.send_invite.side_effect = send_invite
mock_http_client = Mock(spec=[
"get_json",
"post_urlencoded_get_json"
])
# Mocking the response for /info on the IS API.
mock_http_client.get_json.side_effect = get_json
# Mocking the response for /store-invite on the IS API.
mock_http_client.post_urlencoded_get_json.side_effect = post_urlencoded_get_json
self.hs = self.setup_test_homeserver(
config=config,
federation_client=mock_federation_client,
simple_http_client=mock_http_client,
)
return self.hs
def prepare(self, reactor, clock, homeserver):
self.user_id = self.register_user("kermit", "monkey")
self.tok = self.login("kermit", "monkey")
self.restricted_room = self.create_room()
self.unrestricted_room = self.create_room(rule=ACCESS_RULE_UNRESTRICTED)
self.direct_rooms = [
self.create_room(direct=True),
self.create_room(direct=True),
self.create_room(direct=True),
]
self.invitee_id = self.register_user("invitee", "test")
self.invitee_tok = self.login("invitee", "test")
self.helper.invite(
room=self.direct_rooms[0],
src=self.user_id,
targ=self.invitee_id,
tok=self.tok,
)
def test_create_room_no_rule(self):
"""Tests that creating a room with no rule will set the default value."""
room_id = self.create_room()
rule = self.current_rule_in_room(room_id)
self.assertEqual(rule, ACCESS_RULE_RESTRICTED)
def test_create_room_direct_no_rule(self):
"""Tests that creating a direct room with no rule will set the default value."""
room_id = self.create_room(direct=True)
rule = self.current_rule_in_room(room_id)
self.assertEqual(rule, ACCESS_RULE_DIRECT)
def test_create_room_valid_rule(self):
"""Tests that creating a room with a valid rule will set the right value."""
room_id = self.create_room(rule=ACCESS_RULE_UNRESTRICTED)
rule = self.current_rule_in_room(room_id)
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."""
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.
"""
self.create_room(direct=True, rule=ACCESS_RULE_RESTRICTED, expected_code=400)
def test_restricted(self):
"""Tests that in restricted mode we're unable to invite users from blacklisted
servers but can invite other users.
"""
# We can't invite a user from a forbidden HS.
self.helper.invite(
room=self.restricted_room,
src=self.user_id,
targ="@test:forbidden_domain",
tok=self.tok,
expect_code=403,
)
# We can invite a user which HS isn't forbidden.
self.helper.invite(
room=self.restricted_room,
src=self.user_id,
targ="@test:allowed_domain",
tok=self.tok,
expect_code=200,
)
# We can't send a 3PID invite to an address that is mapped to a forbidden HS.
self.send_threepid_invite(
address="test@forbidden_domain",
room_id=self.restricted_room,
expected_code=403,
)
# We can send a 3PID invite to an address that is mapped to an HS that's not
# forbidden.
self.send_threepid_invite(
address="test@allowed_domain",
room_id=self.restricted_room,
expected_code=200,
)
def test_direct(self):
"""Tests that, in direct mode, other users than the initial two can't be invited,
but the following scenario works:
* invited user joins the room
* invited user leaves the room
* room creator re-invites invited user
Also tests that a user from a HS that's in the list of forbidden domains (to use
in restricted mode) can be invited.
"""
not_invited_user = "@not_invited:forbidden_domain"
# We can't invite a new user to the room.
self.helper.invite(
room=self.direct_rooms[0],
src=self.user_id,
targ=not_invited_user,
tok=self.tok,
expect_code=403,
)
# The invited user can join the room.
self.helper.join(
room=self.direct_rooms[0],
user=self.invitee_id,
tok=self.invitee_tok,
expect_code=200,
)
# The invited user can leave the room.
self.helper.leave(
room=self.direct_rooms[0],
user=self.invitee_id,
tok=self.invitee_tok,
expect_code=200,
)
# The invited user can be re-invited to the room.
self.helper.invite(
room=self.direct_rooms[0],
src=self.user_id,
targ=self.invitee_id,
tok=self.tok,
expect_code=200,
)
# If we're alone in the room and have always been the only member, we can invite
# someone.
self.helper.invite(
room=self.direct_rooms[1],
src=self.user_id,
targ=not_invited_user,
tok=self.tok,
expect_code=200,
)
# We can't send a 3PID invite to a room that already has two members.
self.send_threepid_invite(
address="test@allowed_domain",
room_id=self.direct_rooms[0],
expected_code=403,
)
# We can't send a 3PID invite to a room that already has a pending invite.
self.send_threepid_invite(
address="test@allowed_domain",
room_id=self.direct_rooms[1],
expected_code=403,
)
# We can send a 3PID invite to a room in which we've always been the only member.
self.send_threepid_invite(
address="test@forbidden_domain",
room_id=self.direct_rooms[2],
expected_code=200,
)
# We can send a 3PID invite to a room in which there's a 3PID invite.
self.send_threepid_invite(
address="test@forbidden_domain",
room_id=self.direct_rooms[2],
expected_code=403,
)
def test_unrestricted(self):
"""Tests that, in unrestricted mode, we can invite whoever we want.
"""
# We can invite
self.helper.invite(
room=self.unrestricted_room,
src=self.user_id,
targ="@test:forbidden_domain",
tok=self.tok,
expect_code=200,
)
self.helper.invite(
room=self.unrestricted_room,
src=self.user_id,
targ="@test:not_forbidden_domain",
tok=self.tok,
expect_code=200,
)
# We can send a 3PID invite to an address that is mapped to a forbidden HS.
self.send_threepid_invite(
address="test@forbidden_domain",
room_id=self.unrestricted_room,
expected_code=200,
)
# We can send a 3PID invite to an address that is mapped to an HS that's not
# forbidden.
self.send_threepid_invite(
address="test@allowed_domain",
room_id=self.unrestricted_room,
expected_code=200,
)
def test_change_rules(self):
"""Tests that we can only change the current rule from restricted to
unrestricted.
"""
# We can change the rule from restricted to unrestricted.
self.change_rule_in_room(
room_id=self.restricted_room,
new_rule=ACCESS_RULE_UNRESTRICTED,
expected_code=200,
)
# We can't change the rule from restricted to direct.
self.change_rule_in_room(
room_id=self.restricted_room,
new_rule=ACCESS_RULE_DIRECT,
expected_code=403,
)
# We can't change the rule from unrestricted to restricted.
self.change_rule_in_room(
room_id=self.unrestricted_room,
new_rule=ACCESS_RULE_RESTRICTED,
expected_code=403,
)
# We can't change the rule from unrestricted to direct.
self.change_rule_in_room(
room_id=self.unrestricted_room,
new_rule=ACCESS_RULE_DIRECT,
expected_code=403,
)
# We can't change the rule from direct to restricted.
self.change_rule_in_room(
room_id=self.direct_rooms[0],
new_rule=ACCESS_RULE_RESTRICTED,
expected_code=403,
)
# We can't change the rule from direct to unrestricted.
self.change_rule_in_room(
room_id=self.direct_rooms[0],
new_rule=ACCESS_RULE_UNRESTRICTED,
expected_code=403,
)
def create_room(self, direct=False, rule=None, expected_code=200):
content = {
"is_direct": direct,
}
if rule:
content["initial_state"] = [{
"type": ACCESS_RULES_TYPE,
"state_key": "",
"content": {
"rule": rule,
}
}]
request, channel = self.make_request(
"POST",
"/_matrix/client/r0/createRoom",
json.dumps(content),
access_token=self.tok,
)
self.render(request)
self.assertEqual(channel.code, expected_code, channel.result)
if expected_code == 200:
return channel.json_body["room_id"]
def current_rule_in_room(self, room_id):
request, channel = self.make_request(
"GET",
"/_matrix/client/r0/rooms/%s/state/%s" % (room_id, ACCESS_RULES_TYPE),
access_token=self.tok,
)
self.render(request)
self.assertEqual(channel.code, 200, channel.result)
return channel.json_body["rule"]
def change_rule_in_room(self, room_id, new_rule, expected_code=200):
data = {
"rule": new_rule,
}
request, channel = self.make_request(
"PUT",
"/_matrix/client/r0/rooms/%s/state/%s" % (room_id, ACCESS_RULES_TYPE),
json.dumps(data),
access_token=self.tok,
)
self.render(request)
self.assertEqual(channel.code, expected_code, channel.result)
def send_threepid_invite(self, address, room_id, expected_code=200):
params = {
"id_server": "testis",
"medium": "email",
"address": address,
}
request, channel = self.make_request(
"POST",
"/_matrix/client/r0/rooms/%s/invite" % room_id,
json.dumps(params),
access_token=self.tok,
)
self.render(request)
self.assertEqual(channel.code, expected_code, channel.result)