1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
|
# -*- 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
rule = ""
# 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,
}
})
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
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.
"""
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 event.type == EventTypes.PowerLevels:
return self._is_power_level_content_allowed(event.content, rule)
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
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 _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.
"""
# 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 _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.
"""
return True
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
room before the event was sent.
Returns:
bool, True if the event can be allowed, False otherwise.
"""
# 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
def _is_power_level_content_allowed(self, content, access_rule):
"""Denies a power level events that sets 'users_default' to a non-0 value, and
sets the PL of a user that'd be blacklisted in restricted mode to a non-default
value.
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.
"""
# Blacklisted servers shouldn't have any restriction in "direct" mode, so always
# accept the event.
if access_rule == ACCESS_RULE_DIRECT:
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.
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
|