mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 11:17:51 +00:00
Use the Rust auth rules from Python
check_state_dependent_auth_rules and auth_types_for_event now call the Rust implementation, so every caller (federation, sticky events, state res v1 and v2) uses it. The Python helpers that only they used are gone. The ed25519 check for third-party invites stays in Python, as a helper the Rust rule calls. auth_types_for_event returns its pairs in the order the Python added them. Event creation lists the auth events in the set's iteration order, so a Rust HashSet's random order changed event IDs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
This commit is contained in:
1 parent
afae38f4ee
commit
34fdef7a9d
7 files changed
+160
-674
No files matched your search
+34
-14
@@ -25,10 +25,11 @@
|
||||
//! State resolution skips an event on the first kind and aborts on the second,
|
||||
//! because `_iterative_auth_checks` only catches `AuthError`.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use log::debug;
|
||||
use pyo3::{
|
||||
exceptions::PyAttributeError,
|
||||
prelude::*,
|
||||
types::{PyModule, PySet, PyTuple},
|
||||
};
|
||||
@@ -547,10 +548,15 @@ fn verify_third_party_invite(
|
||||
/// The result may be a superset of what is needed, depending on the room
|
||||
/// state. A non-string `third_party_invite` token or authorising user is left
|
||||
/// out, as no state key can match it.
|
||||
///
|
||||
/// The pairs are distinct and in the order the Python adds them to its set.
|
||||
/// The order matters to Python callers: building the same set in the same
|
||||
/// order gives the same iteration order, and event creation lists the auth
|
||||
/// events in that order, which feeds into the event ID.
|
||||
pub fn auth_types_for_event<'a>(
|
||||
room_version: &RoomVersion,
|
||||
event: &'a Event,
|
||||
) -> Result<HashSet<(&'a str, &'a str)>, AuthFailure> {
|
||||
) -> Result<Vec<(&'a str, &'a str)>, AuthFailure> {
|
||||
auth_types_for(
|
||||
room_version,
|
||||
event.type_(),
|
||||
@@ -560,16 +566,16 @@ pub fn auth_types_for_event<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
fn auth_types_for<'a>(
|
||||
pub(crate) fn auth_types_for<'a>(
|
||||
room_version: &RoomVersion,
|
||||
event_type: &'a str,
|
||||
sender: &'a str,
|
||||
state_key: Option<&'a str>,
|
||||
content: &'a BTreeMap<Box<str>, Value>,
|
||||
) -> Result<HashSet<(&'a str, &'a str)>, AuthFailure> {
|
||||
let mut auth_types = HashSet::new();
|
||||
) -> Result<Vec<(&'a str, &'a str)>, AuthFailure> {
|
||||
let mut auth_types = OrderedSet::default();
|
||||
if event_type == M_ROOM_CREATE {
|
||||
return Ok(auth_types);
|
||||
return Ok(auth_types.0);
|
||||
}
|
||||
|
||||
auth_types.insert(POWER_LEVELS_KEY);
|
||||
@@ -613,7 +619,20 @@ fn auth_types_for<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(auth_types)
|
||||
Ok(auth_types.0)
|
||||
}
|
||||
|
||||
/// A set that keeps insertion order. It holds at most seven pairs, so a
|
||||
/// linear scan is cheapest.
|
||||
#[derive(Default)]
|
||||
struct OrderedSet<'a>(Vec<(&'a str, &'a str)>);
|
||||
|
||||
impl<'a> OrderedSet<'a> {
|
||||
fn insert(&mut self, pair: (&'a str, &'a str)) {
|
||||
if !self.0.contains(&pair) {
|
||||
self.0.push(pair);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Python entry point for [`check_state_dependent_auth_rules`].
|
||||
@@ -640,15 +659,15 @@ fn check_state_dependent_auth_rules_py(
|
||||
}
|
||||
|
||||
/// Python entry point for [`auth_types_for_event`]. Also accepts an
|
||||
/// `EventBuilder`, read through its `type`, `sender`, `state_key` and
|
||||
/// `content` attributes.
|
||||
/// `EventBuilder` (or any object with `type`, `sender`, `state_key` and
|
||||
/// `content` attributes).
|
||||
#[pyfunction(name = "auth_types_for_event")]
|
||||
fn auth_types_for_event_py<'py>(
|
||||
py: Python<'py>,
|
||||
room_version: &RoomVersion,
|
||||
event: &Bound<'py, PyAny>,
|
||||
) -> PyResult<Bound<'py, PySet>> {
|
||||
let to_py = |types: HashSet<(&str, &str)>| {
|
||||
let to_py = |types: Vec<(&str, &str)>| {
|
||||
let tuples = types
|
||||
.into_iter()
|
||||
.map(|(t, k)| PyTuple::new(py, [t, k]))
|
||||
@@ -664,10 +683,11 @@ fn auth_types_for_event_py<'py>(
|
||||
|
||||
let event_type: String = event.getattr("type")?.extract()?;
|
||||
let sender: String = event.getattr("sender")?.extract()?;
|
||||
let state_key: Option<String> = if event.call_method0("is_state")?.extract()? {
|
||||
Some(event.getattr("state_key")?.extract()?)
|
||||
} else {
|
||||
None
|
||||
// `EventBuilder.state_key` raises `AttributeError` for non-state events.
|
||||
let state_key: Option<String> = match event.getattr("state_key") {
|
||||
Ok(state_key) => state_key.extract()?,
|
||||
Err(err) if err.is_instance_of::<PyAttributeError>(py) => None,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let content: BTreeMap<Box<str>, Value> = depythonize(&event.getattr("content")?)?;
|
||||
let types = auth_types_for(
|
||||
|
||||
@@ -552,19 +552,31 @@ fn auth_types() {
|
||||
"content": {"membership": "invite",
|
||||
"third_party_invite": {"signed": {"token": "tok"}}}}),
|
||||
);
|
||||
// In the order the Python adds them.
|
||||
let types = auth_types_for_event(V6, &invite).unwrap();
|
||||
let expected: HashSet<(&str, &str)> = [
|
||||
let expected = vec![
|
||||
POWER_LEVELS_KEY,
|
||||
(M_ROOM_MEMBER, CREATOR),
|
||||
CREATE_KEY,
|
||||
JOIN_RULES_KEY,
|
||||
(M_ROOM_MEMBER, CREATOR),
|
||||
(M_ROOM_MEMBER, JOINER),
|
||||
(M_ROOM_THIRD_PARTY_INVITE, "tok"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
];
|
||||
assert_eq!(types, expected);
|
||||
|
||||
// Pairs are distinct: a self-invite names the sender twice.
|
||||
let self_join = member(V6, JOINER, "join");
|
||||
let types = auth_types_for_event(V6, &self_join).unwrap();
|
||||
assert_eq!(
|
||||
types,
|
||||
vec![
|
||||
POWER_LEVELS_KEY,
|
||||
(M_ROOM_MEMBER, JOINER),
|
||||
CREATE_KEY,
|
||||
JOIN_RULES_KEY
|
||||
]
|
||||
);
|
||||
|
||||
assert!(auth_types_for_event(V6, &create(V6)).unwrap().is_empty());
|
||||
}
|
||||
|
||||
|
||||
+26
-636
@@ -20,8 +20,6 @@
|
||||
#
|
||||
#
|
||||
|
||||
import collections
|
||||
import collections.abc
|
||||
import logging
|
||||
import typing
|
||||
from collections import ChainMap
|
||||
@@ -46,15 +44,11 @@ from synapse.api.constants import (
|
||||
MAX_PDU_SIZE,
|
||||
EventContentFields,
|
||||
EventTypes,
|
||||
JoinRules,
|
||||
Membership,
|
||||
)
|
||||
from synapse.api.errors import (
|
||||
AuthError,
|
||||
Codes,
|
||||
EventSizeError,
|
||||
SynapseError,
|
||||
UnstableSpecAuthError,
|
||||
)
|
||||
from synapse.api.room_versions import (
|
||||
KNOWN_ROOM_VERSIONS,
|
||||
@@ -65,6 +59,7 @@ from synapse.events import is_creator
|
||||
from synapse.events.py_protocol import supports_msc4242_state_dag
|
||||
from synapse.state import CREATE_KEY
|
||||
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
|
||||
from synapse.synapse_rust import event_auth as _rust_event_auth
|
||||
from synapse.types import (
|
||||
JsonMapping,
|
||||
MutableStateMap,
|
||||
@@ -350,7 +345,7 @@ def check_state_dependent_auth_rules(
|
||||
"""Check that an event complies with auth rules that depend on room state
|
||||
|
||||
Runs through the parts of the auth rules that check an event against bits of room
|
||||
state.
|
||||
state. The rules are implemented in Rust, in `synapse_rust.event_auth`.
|
||||
|
||||
Note:
|
||||
|
||||
@@ -367,85 +362,10 @@ def check_state_dependent_auth_rules(
|
||||
|
||||
Raises:
|
||||
AuthError if the checks fail
|
||||
SynapseError if the event or its auth events are malformed (e.g. invalid
|
||||
power levels)
|
||||
"""
|
||||
# there are no state-dependent auth rules for create events.
|
||||
if event.type == EventTypes.Create:
|
||||
logger.debug("Allowing! %s", event)
|
||||
return
|
||||
|
||||
auth_dict = {(e.type, e.state_key): e for e in auth_events}
|
||||
|
||||
# Later code relies on there being a create event e.g _can_federate, _is_membership_change_allowed
|
||||
# so produce a more intelligible error if we don't have one.
|
||||
create_event = auth_dict.get(CREATE_KEY)
|
||||
if create_event is None:
|
||||
raise AuthError(
|
||||
403, f"Event {event.event_id} is missing a create event in auth_events."
|
||||
)
|
||||
|
||||
# additional check for m.federate
|
||||
creating_domain = get_domain_from_id(create_event.sender)
|
||||
originating_domain = get_domain_from_id(event.sender)
|
||||
if creating_domain != originating_domain:
|
||||
if not _can_federate(event, auth_dict):
|
||||
raise AuthError(403, "This room has been marked as unfederatable.")
|
||||
|
||||
# 4. If type is m.room.aliases
|
||||
if (
|
||||
event.type == EventTypes.Aliases
|
||||
and event.room_version.special_case_aliases_auth
|
||||
):
|
||||
# 4a. If event has no state_key, reject
|
||||
if not event.is_state():
|
||||
raise AuthError(403, "Alias event must be a state event")
|
||||
if not event.state_key:
|
||||
raise AuthError(403, "Alias event must have non-empty state_key")
|
||||
|
||||
# 4b. If sender's domain doesn't matches [sic] state_key, reject
|
||||
sender_domain = get_domain_from_id(event.sender)
|
||||
if event.state_key != sender_domain:
|
||||
raise AuthError(
|
||||
403, "Alias event's state_key does not match sender's domain"
|
||||
)
|
||||
|
||||
# 4c. Otherwise, allow.
|
||||
logger.debug("Allowing! %s", event)
|
||||
return
|
||||
|
||||
# 5. If type is m.room.membership
|
||||
if event.type == EventTypes.Member:
|
||||
_is_membership_change_allowed(event.room_version, event, auth_dict)
|
||||
logger.debug("Allowing! %s", event)
|
||||
return
|
||||
|
||||
_check_event_sender_in_room(event, auth_dict)
|
||||
|
||||
# Special case to allow m.room.third_party_invite events wherever
|
||||
# a user is allowed to issue invites. Fixes
|
||||
# https://github.com/vector-im/vector-web/issues/1208 hopefully
|
||||
if event.type == EventTypes.ThirdPartyInvite:
|
||||
user_level = get_user_power_level(event.sender, auth_dict)
|
||||
invite_level = get_named_level(auth_dict, "invite", 0)
|
||||
|
||||
if user_level < invite_level:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You don't have permission to invite users",
|
||||
errcode=Codes.INSUFFICIENT_POWER,
|
||||
)
|
||||
else:
|
||||
logger.debug("Allowing! %s", event)
|
||||
return
|
||||
|
||||
_can_send_event(event, auth_dict)
|
||||
|
||||
if event.type == EventTypes.PowerLevels:
|
||||
_check_power_levels(event.room_version, event, auth_dict)
|
||||
|
||||
if event.type == EventTypes.Redaction:
|
||||
check_redaction(event.room_version, event, auth_dict)
|
||||
|
||||
logger.debug("Allowing! %s", event)
|
||||
_rust_event_auth.check_state_dependent_auth_rules(event, auth_events)
|
||||
|
||||
|
||||
def _check_size_limits(event: "EventBase") -> None:
|
||||
@@ -562,282 +482,6 @@ def _check_create(event: "EventBase") -> None:
|
||||
)
|
||||
|
||||
|
||||
def _can_federate(event: "EventBase", auth_events: StateMap["EventBase"]) -> bool:
|
||||
creation_event = auth_events.get((EventTypes.Create, ""))
|
||||
# There should always be a creation event, but if not don't federate.
|
||||
if not creation_event:
|
||||
return False
|
||||
|
||||
return creation_event.content.get(EventContentFields.FEDERATE, True) is True
|
||||
|
||||
|
||||
def _is_membership_change_allowed(
|
||||
room_version: RoomVersion, event: "EventBase", auth_events: StateMap["EventBase"]
|
||||
) -> None:
|
||||
"""
|
||||
Confirms that the event which changes membership is an allowed change.
|
||||
|
||||
Args:
|
||||
room_version: The version of the room.
|
||||
event: The event to check.
|
||||
auth_events: The current auth events of the room.
|
||||
|
||||
Raises:
|
||||
AuthError if the event is not allowed.
|
||||
"""
|
||||
membership = event.content["membership"]
|
||||
|
||||
# Check if this is the room creator joining:
|
||||
if len(event.prev_event_ids()) == 1 and Membership.JOIN == membership:
|
||||
# Get room creation event:
|
||||
key = (EventTypes.Create, "")
|
||||
create = auth_events.get(key)
|
||||
if create and event.prev_event_ids()[0] == create.event_id:
|
||||
if room_version.implicit_room_creator:
|
||||
creator = create.sender
|
||||
else:
|
||||
creator = create.content[EventContentFields.ROOM_CREATOR]
|
||||
if creator == event.state_key:
|
||||
return
|
||||
|
||||
target_user_id = event.state_key
|
||||
|
||||
# We need the create event in order to check if we can federate or not.
|
||||
# If it's missing, yell loudly. Previously we only did this inside the
|
||||
# _can_federate check.
|
||||
create_event = auth_events.get((EventTypes.Create, ""))
|
||||
if not create_event:
|
||||
raise AuthError(403, "Create event missing from auth_events")
|
||||
creating_domain = get_domain_from_id(create_event.sender)
|
||||
target_domain = get_domain_from_id(target_user_id)
|
||||
if creating_domain != target_domain:
|
||||
if not _can_federate(event, auth_events):
|
||||
raise AuthError(403, "This room has been marked as unfederatable.")
|
||||
|
||||
# get info about the caller
|
||||
key = (EventTypes.Member, event.sender)
|
||||
caller = auth_events.get(key)
|
||||
|
||||
caller_in_room = caller and caller.membership == Membership.JOIN
|
||||
caller_invited = caller and caller.membership == Membership.INVITE
|
||||
caller_knocked = (
|
||||
caller
|
||||
and room_version.knock_join_rule
|
||||
and caller.membership == Membership.KNOCK
|
||||
)
|
||||
|
||||
# get info about the target
|
||||
key = (EventTypes.Member, target_user_id)
|
||||
target = auth_events.get(key)
|
||||
|
||||
target_in_room = target and target.membership == Membership.JOIN
|
||||
target_banned = target and target.membership == Membership.BAN
|
||||
|
||||
key = (EventTypes.JoinRules, "")
|
||||
join_rule_event = auth_events.get(key)
|
||||
if join_rule_event:
|
||||
join_rule = join_rule_event.content.get("join_rule", JoinRules.INVITE)
|
||||
else:
|
||||
join_rule = JoinRules.INVITE
|
||||
|
||||
user_level = get_user_power_level(event.sender, auth_events)
|
||||
target_level = get_user_power_level(target_user_id, auth_events)
|
||||
|
||||
invite_level = get_named_level(auth_events, "invite", 0)
|
||||
ban_level = get_named_level(auth_events, "ban", 50)
|
||||
|
||||
logger.debug(
|
||||
"_is_membership_change_allowed: %s",
|
||||
{
|
||||
"caller_membership": caller.membership if caller else None,
|
||||
"caller_in_room": caller_in_room,
|
||||
"caller_invited": caller_invited,
|
||||
"caller_knocked": caller_knocked,
|
||||
"target_banned": target_banned,
|
||||
"target_in_room": target_in_room,
|
||||
"membership": membership,
|
||||
"join_rule": join_rule,
|
||||
"target_user_id": target_user_id,
|
||||
"event.sender": event.sender,
|
||||
},
|
||||
)
|
||||
|
||||
if Membership.INVITE == membership and "third_party_invite" in event.content:
|
||||
if not _verify_third_party_invite(event, auth_events):
|
||||
raise AuthError(403, "You are not invited to this room.")
|
||||
if target_banned:
|
||||
raise AuthError(403, "%s is banned from the room" % (target_user_id,))
|
||||
return
|
||||
|
||||
# Require the user to be in the room for membership changes other than join/knock.
|
||||
# Note that the room version check for knocking is done implicitly by `caller_knocked`
|
||||
# and the ability to set a membership of `knock` in the first place.
|
||||
if Membership.JOIN != membership and Membership.KNOCK != membership:
|
||||
# If the user has been invited or has knocked, they are allowed to change their
|
||||
# membership event to leave
|
||||
if (
|
||||
(caller_invited or caller_knocked)
|
||||
and Membership.LEAVE == membership
|
||||
and target_user_id == event.sender
|
||||
):
|
||||
return
|
||||
|
||||
if not caller_in_room: # caller isn't joined
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"%s not in room %s." % (event.sender, event.room_id),
|
||||
errcode=Codes.NOT_JOINED,
|
||||
)
|
||||
|
||||
if Membership.INVITE == membership:
|
||||
# TODO (erikj): We should probably handle this more intelligently
|
||||
# PRIVATE join rules.
|
||||
|
||||
# Invites are valid iff caller is in the room and target isn't.
|
||||
if target_banned:
|
||||
raise AuthError(403, "%s is banned from the room" % (target_user_id,))
|
||||
elif target_in_room: # the target is already in the room.
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"%s is already in the room." % target_user_id,
|
||||
errcode=Codes.ALREADY_JOINED,
|
||||
)
|
||||
else:
|
||||
if user_level < invite_level:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You don't have permission to invite users",
|
||||
errcode=Codes.INSUFFICIENT_POWER,
|
||||
)
|
||||
elif Membership.JOIN == membership:
|
||||
# Joins are valid iff caller == target and:
|
||||
# * They are not banned.
|
||||
# * They are accepting a previously sent invitation.
|
||||
# * They are already joined (it's a NOOP).
|
||||
# * The room is public.
|
||||
# * The room is restricted and the user meets the allows rules.
|
||||
if event.sender != target_user_id:
|
||||
raise AuthError(403, "Cannot force another user to join.")
|
||||
elif target_banned:
|
||||
raise AuthError(403, "You are banned from this room")
|
||||
elif join_rule == JoinRules.PUBLIC:
|
||||
pass
|
||||
elif (
|
||||
room_version.restricted_join_rule and join_rule == JoinRules.RESTRICTED
|
||||
) or (
|
||||
room_version.knock_restricted_join_rule
|
||||
and join_rule == JoinRules.KNOCK_RESTRICTED
|
||||
):
|
||||
# This is the same as public, but the event must contain a reference
|
||||
# to the server who authorised the join. If the event does not contain
|
||||
# the proper content it is rejected.
|
||||
#
|
||||
# Note that if the caller is in the room or invited, then they do
|
||||
# not need to meet the allow rules.
|
||||
if not caller_in_room and not caller_invited:
|
||||
authorising_user = event.content.get(
|
||||
EventContentFields.AUTHORISING_USER
|
||||
)
|
||||
|
||||
if authorising_user is None:
|
||||
raise AuthError(403, "Join event is missing authorising user.")
|
||||
|
||||
# The authorising user must be in the room.
|
||||
key = (EventTypes.Member, authorising_user)
|
||||
member_event = auth_events.get(key)
|
||||
_check_joined_room(member_event, authorising_user, event.room_id)
|
||||
|
||||
authorising_user_level = get_user_power_level(
|
||||
authorising_user, auth_events
|
||||
)
|
||||
if authorising_user_level < invite_level:
|
||||
raise AuthError(403, "Join event authorised by invalid server.")
|
||||
|
||||
elif (
|
||||
join_rule == JoinRules.INVITE
|
||||
or (room_version.knock_join_rule and join_rule == JoinRules.KNOCK)
|
||||
or (
|
||||
room_version.knock_restricted_join_rule
|
||||
and join_rule == JoinRules.KNOCK_RESTRICTED
|
||||
)
|
||||
):
|
||||
# You can only join the room if you are invited or are already in the room.
|
||||
if not (caller_in_room or caller_invited):
|
||||
raise AuthError(403, "You are not invited to this room.")
|
||||
else:
|
||||
# TODO (erikj): may_join list
|
||||
# TODO (erikj): private rooms
|
||||
raise AuthError(403, "You are not allowed to join this room")
|
||||
elif Membership.LEAVE == membership:
|
||||
# TODO (erikj): Implement kicks.
|
||||
if target_banned and user_level < ban_level:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You cannot unban user %s." % (target_user_id,),
|
||||
errcode=Codes.INSUFFICIENT_POWER,
|
||||
)
|
||||
elif target_user_id != event.sender:
|
||||
kick_level = get_named_level(auth_events, "kick", 50)
|
||||
|
||||
if user_level < kick_level or user_level <= target_level:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You cannot kick user %s." % target_user_id,
|
||||
errcode=Codes.INSUFFICIENT_POWER,
|
||||
)
|
||||
elif Membership.BAN == membership:
|
||||
if user_level < ban_level:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You don't have permission to ban",
|
||||
errcode=Codes.INSUFFICIENT_POWER,
|
||||
)
|
||||
elif user_level <= target_level:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You don't have permission to ban this user",
|
||||
errcode=Codes.INSUFFICIENT_POWER,
|
||||
)
|
||||
elif room_version.knock_join_rule and Membership.KNOCK == membership:
|
||||
if join_rule != JoinRules.KNOCK and (
|
||||
not room_version.knock_restricted_join_rule
|
||||
or join_rule != JoinRules.KNOCK_RESTRICTED
|
||||
):
|
||||
raise AuthError(403, "You don't have permission to knock")
|
||||
elif target_user_id != event.sender:
|
||||
raise AuthError(403, "You cannot knock for other users")
|
||||
elif target_in_room:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You cannot knock on a room you are already in",
|
||||
errcode=Codes.ALREADY_JOINED,
|
||||
)
|
||||
elif caller_invited:
|
||||
raise AuthError(403, "You are already invited to this room")
|
||||
elif target_banned:
|
||||
raise AuthError(403, "You are banned from this room")
|
||||
else:
|
||||
raise AuthError(500, "Unknown membership %s" % membership)
|
||||
|
||||
|
||||
def _check_event_sender_in_room(
|
||||
event: "EventBase", auth_events: StateMap["EventBase"]
|
||||
) -> None:
|
||||
key = (EventTypes.Member, event.sender)
|
||||
member_event = auth_events.get(key)
|
||||
|
||||
_check_joined_room(member_event, event.sender, event.room_id)
|
||||
|
||||
|
||||
def _check_joined_room(
|
||||
member: Optional["EventBase"], user_id: str, room_id: str
|
||||
) -> None:
|
||||
if not member or member.membership != Membership.JOIN:
|
||||
raise AuthError(
|
||||
403, "User %s not in room %s (%s)" % (user_id, room_id, repr(member))
|
||||
)
|
||||
|
||||
|
||||
def get_send_level(
|
||||
etype: str, state_key: str | None, power_levels_event: Optional["EventBase"]
|
||||
) -> int:
|
||||
@@ -876,53 +520,6 @@ def get_send_level(
|
||||
return int(send_level)
|
||||
|
||||
|
||||
def _can_send_event(event: "EventBase", auth_events: StateMap["EventBase"]) -> bool:
|
||||
state_key = event.get_state_key()
|
||||
power_levels_event = get_power_level_event(auth_events)
|
||||
|
||||
send_level = get_send_level(event.type, state_key, power_levels_event)
|
||||
user_level = get_user_power_level(event.sender, auth_events)
|
||||
|
||||
if user_level < send_level:
|
||||
raise UnstableSpecAuthError(
|
||||
403,
|
||||
"You don't have permission to post that to the room. "
|
||||
+ "user_level (%d) < send_level (%d)" % (user_level, send_level),
|
||||
errcode=Codes.INSUFFICIENT_POWER,
|
||||
)
|
||||
|
||||
if (
|
||||
state_key is not None
|
||||
and state_key.startswith("@")
|
||||
and state_key != event.sender
|
||||
):
|
||||
if event.room_version.msc3757_enabled:
|
||||
try:
|
||||
colon_idx = state_key.index(":", 1)
|
||||
suffix_idx = state_key.find("_", colon_idx + 1)
|
||||
state_key_user_id = (
|
||||
state_key[:suffix_idx] if suffix_idx != -1 else state_key
|
||||
)
|
||||
if not UserID.is_valid(state_key_user_id):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise SynapseError(
|
||||
400,
|
||||
"State key neither equals a valid user ID, nor starts with one plus an underscore",
|
||||
errcode=Codes.BAD_JSON,
|
||||
)
|
||||
if (
|
||||
# sender is owner of the state key
|
||||
state_key_user_id == event.sender
|
||||
# sender has higher PL than the owner of the state key
|
||||
or user_level > get_user_power_level(state_key_user_id, auth_events)
|
||||
):
|
||||
return True
|
||||
raise AuthError(403, "You are not allowed to set others state")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_redaction(
|
||||
room_version_obj: RoomVersion,
|
||||
event: "EventBase",
|
||||
@@ -961,154 +558,6 @@ def check_redaction(
|
||||
raise AuthError(403, "You don't have permission to redact events")
|
||||
|
||||
|
||||
def _check_power_levels(
|
||||
room_version_obj: RoomVersion,
|
||||
event: "EventBase",
|
||||
auth_events: StateMap["EventBase"],
|
||||
) -> None:
|
||||
user_list = event.content.get("users", {})
|
||||
# Validate users
|
||||
for k, v in user_list.items():
|
||||
try:
|
||||
UserID.from_string(k)
|
||||
except Exception:
|
||||
raise SynapseError(400, "Not a valid user_id: %s" % (k,))
|
||||
|
||||
try:
|
||||
int(v)
|
||||
except Exception:
|
||||
raise SynapseError(400, "Not a valid power level: %s" % (v,))
|
||||
|
||||
if room_version_obj.msc4289_creator_power_enabled:
|
||||
# Enforce the creator does not appear in the users map
|
||||
create_event = auth_events.get((EventTypes.Create, ""))
|
||||
if not create_event:
|
||||
raise SynapseError(
|
||||
400, "Cannot check power levels without a create event in auth_events"
|
||||
)
|
||||
if create_event.sender in user_list:
|
||||
raise SynapseError(
|
||||
400,
|
||||
"Creator user %s must not appear in content.users"
|
||||
% (create_event.sender,),
|
||||
)
|
||||
additional_creators = create_event.content.get(
|
||||
EventContentFields.ADDITIONAL_CREATORS, []
|
||||
)
|
||||
if additional_creators:
|
||||
creators_in_user_list = set(additional_creators).intersection(
|
||||
set(user_list)
|
||||
)
|
||||
if len(creators_in_user_list) > 0:
|
||||
raise SynapseError(
|
||||
400,
|
||||
"Additional creators users must not appear in content.users",
|
||||
)
|
||||
|
||||
# Reject events with stringy power levels if required by room version
|
||||
if (
|
||||
event.type == EventTypes.PowerLevels
|
||||
and room_version_obj.enforce_int_power_levels
|
||||
):
|
||||
for k, v in event.content.items():
|
||||
if k in {
|
||||
"users_default",
|
||||
"events_default",
|
||||
"state_default",
|
||||
"ban",
|
||||
"redact",
|
||||
"kick",
|
||||
"invite",
|
||||
}:
|
||||
if type(v) is not int: # noqa: E721
|
||||
raise SynapseError(400, f"{v!r} must be an integer.")
|
||||
if k in {"events", "notifications", "users"}:
|
||||
if not isinstance(v, collections.abc.Mapping) or not all(
|
||||
type(v) is int
|
||||
for v in v.values() # noqa: E721
|
||||
):
|
||||
raise SynapseError(
|
||||
400,
|
||||
f"{v!r} must be a dict wherein all the values are integers.",
|
||||
)
|
||||
|
||||
key = (event.type, event.state_key)
|
||||
current_state = auth_events.get(key)
|
||||
|
||||
if not current_state:
|
||||
return
|
||||
|
||||
user_level = get_user_power_level(event.sender, auth_events)
|
||||
|
||||
# Check other levels:
|
||||
levels_to_check: list[tuple[str, str | None]] = [
|
||||
("users_default", None),
|
||||
("events_default", None),
|
||||
("state_default", None),
|
||||
("ban", None),
|
||||
("redact", None),
|
||||
("kick", None),
|
||||
("invite", None),
|
||||
]
|
||||
|
||||
old_list = current_state.content.get("users", {})
|
||||
for user in set(list(old_list) + list(user_list)):
|
||||
levels_to_check.append((user, "users"))
|
||||
|
||||
old_list = current_state.content.get("events", {})
|
||||
new_list = event.content.get("events", {})
|
||||
for ev_id in set(list(old_list) + list(new_list)):
|
||||
levels_to_check.append((ev_id, "events"))
|
||||
|
||||
# MSC2209 specifies these checks should also be done for the "notifications"
|
||||
# key.
|
||||
if room_version_obj.limit_notifications_power_levels:
|
||||
old_list = current_state.content.get("notifications", {})
|
||||
new_list = event.content.get("notifications", {})
|
||||
for ev_id in set(list(old_list) + list(new_list)):
|
||||
levels_to_check.append((ev_id, "notifications"))
|
||||
|
||||
old_state = current_state.content
|
||||
new_state = event.content
|
||||
|
||||
for level_to_check, dir in levels_to_check:
|
||||
old_loc = old_state
|
||||
new_loc = new_state
|
||||
if dir:
|
||||
old_loc = old_loc.get(dir, {})
|
||||
new_loc = new_loc.get(dir, {})
|
||||
|
||||
if level_to_check in old_loc:
|
||||
old_level: int | None = int(old_loc[level_to_check])
|
||||
else:
|
||||
old_level = None
|
||||
|
||||
if level_to_check in new_loc:
|
||||
new_level: int | None = int(new_loc[level_to_check])
|
||||
else:
|
||||
new_level = None
|
||||
|
||||
if new_level is not None and old_level is not None:
|
||||
if new_level == old_level:
|
||||
continue
|
||||
|
||||
if dir == "users" and level_to_check != event.sender:
|
||||
if old_level == user_level:
|
||||
raise AuthError(
|
||||
403,
|
||||
"You don't have permission to remove ops level equal to your own",
|
||||
)
|
||||
|
||||
# Check if the old and new levels are greater than the user level
|
||||
# (if defined)
|
||||
old_level_too_big = old_level is not None and old_level > user_level
|
||||
new_level_too_big = new_level is not None and new_level > user_level
|
||||
if old_level_too_big or new_level_too_big:
|
||||
raise AuthError(
|
||||
403, "You don't have permission to add ops level greater than your own"
|
||||
)
|
||||
|
||||
|
||||
def get_power_level_event(auth_events: StateMap["EventBase"]) -> Optional["EventBase"]:
|
||||
return auth_events.get((EventTypes.PowerLevels, ""))
|
||||
|
||||
@@ -1168,54 +617,23 @@ def get_named_level(auth_events: StateMap["EventBase"], name: str, default: int)
|
||||
return default
|
||||
|
||||
|
||||
def _verify_third_party_invite(
|
||||
event: "EventBase", auth_events: StateMap["EventBase"]
|
||||
def _verify_third_party_invite_signatures(
|
||||
signed: JsonMapping, invite_content: JsonMapping
|
||||
) -> bool:
|
||||
"""
|
||||
Validates that the invite event is authorized by a previous third-party invite.
|
||||
"""Check the signatures on the `signed` block of a third-party invite.
|
||||
|
||||
Checks that the public key, and keyserver, match those in the third party invite,
|
||||
and that the invite event has a signature issued using that public key.
|
||||
Called from the Rust auth rules once the structural checks on the invite
|
||||
have passed. Checks that `signed` has an ed25519 signature from one of the
|
||||
public keys in the `m.room.third_party_invite` event's content.
|
||||
|
||||
Args:
|
||||
event: The m.room.member join event being validated.
|
||||
auth_events: All relevant previous context events which may be used
|
||||
for authorization decisions.
|
||||
signed: the `third_party_invite.signed` block of the invite event.
|
||||
invite_content: the content of the `m.room.third_party_invite` event.
|
||||
|
||||
Return:
|
||||
True if the event fulfills the expectations of a previous third party
|
||||
invite event.
|
||||
Returns:
|
||||
True if `signed` was signed with one of the invite's public keys.
|
||||
"""
|
||||
if "third_party_invite" not in event.content:
|
||||
return False
|
||||
third_party_invite = event.content["third_party_invite"]
|
||||
if not isinstance(third_party_invite, collections.abc.Mapping):
|
||||
return False
|
||||
if "signed" not in third_party_invite:
|
||||
return False
|
||||
signed = third_party_invite["signed"]
|
||||
if not isinstance(signed, collections.abc.Mapping):
|
||||
return False
|
||||
for key in {"mxid", "token", "signatures"}:
|
||||
if key not in signed:
|
||||
return False
|
||||
|
||||
token = signed["token"]
|
||||
|
||||
invite_event = auth_events.get((EventTypes.ThirdPartyInvite, token))
|
||||
if not invite_event:
|
||||
return False
|
||||
|
||||
if invite_event.sender != event.sender:
|
||||
return False
|
||||
|
||||
if event.sender != invite_event.sender:
|
||||
return False
|
||||
|
||||
if signed["mxid"] != event.state_key:
|
||||
return False
|
||||
|
||||
for public_key_object in get_public_keys(invite_event):
|
||||
for public_key_object in _get_public_keys_from_content(invite_content):
|
||||
public_key = public_key_object["public_key"]
|
||||
try:
|
||||
for server, signature_block in signed["signatures"].items():
|
||||
@@ -1240,13 +658,17 @@ def _verify_third_party_invite(
|
||||
|
||||
|
||||
def get_public_keys(invite_event: "EventBase") -> list[dict[str, Any]]:
|
||||
return _get_public_keys_from_content(invite_event.content)
|
||||
|
||||
|
||||
def _get_public_keys_from_content(content: JsonMapping) -> list[dict[str, Any]]:
|
||||
public_keys = []
|
||||
if "public_key" in invite_event.content:
|
||||
o = {"public_key": invite_event.content["public_key"]}
|
||||
if "key_validity_url" in invite_event.content:
|
||||
o["key_validity_url"] = invite_event.content["key_validity_url"]
|
||||
if "public_key" in content:
|
||||
o = {"public_key": content["public_key"]}
|
||||
if "key_validity_url" in content:
|
||||
o["key_validity_url"] = content["key_validity_url"]
|
||||
public_keys.append(o)
|
||||
public_keys.extend(invite_event.content.get("public_keys", []))
|
||||
public_keys.extend(content.get("public_keys", []))
|
||||
return public_keys
|
||||
|
||||
|
||||
@@ -1260,39 +682,7 @@ def auth_types_for_event(
|
||||
Used to limit the number of events to fetch from the database to
|
||||
actually auth the event.
|
||||
"""
|
||||
if event.type == EventTypes.Create:
|
||||
return set()
|
||||
|
||||
auth_types = {
|
||||
(EventTypes.PowerLevels, ""),
|
||||
(EventTypes.Member, event.sender),
|
||||
(EventTypes.Create, ""),
|
||||
}
|
||||
|
||||
if event.type == EventTypes.Member:
|
||||
membership = event.content["membership"]
|
||||
if membership in [Membership.JOIN, Membership.INVITE, Membership.KNOCK]:
|
||||
auth_types.add((EventTypes.JoinRules, ""))
|
||||
|
||||
auth_types.add((EventTypes.Member, event.state_key))
|
||||
|
||||
if membership == Membership.INVITE:
|
||||
if "third_party_invite" in event.content:
|
||||
key = (
|
||||
EventTypes.ThirdPartyInvite,
|
||||
event.content["third_party_invite"]["signed"]["token"],
|
||||
)
|
||||
auth_types.add(key)
|
||||
|
||||
if room_version.restricted_join_rule and membership == Membership.JOIN:
|
||||
if EventContentFields.AUTHORISING_USER in event.content:
|
||||
key = (
|
||||
EventTypes.Member,
|
||||
event.content[EventContentFields.AUTHORISING_USER],
|
||||
)
|
||||
auth_types.add(key)
|
||||
|
||||
return auth_types
|
||||
return _rust_event_auth.auth_types_for_event(room_version, event)
|
||||
|
||||
|
||||
def check_valid_additional_creators(additional_creators: Any) -> None:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# This file is licensed under the Affero General Public License (AGPL) version 3.
|
||||
#
|
||||
# Copyright (C) 2026 Element Creations 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>.
|
||||
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from synapse.api.room_versions import RoomVersion
|
||||
from synapse.synapse_rust.events import Event
|
||||
from synapse.types import StateKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.events.builder import EventBuilder
|
||||
|
||||
def check_state_dependent_auth_rules(
|
||||
event: Event, auth_events: Iterable[Event]
|
||||
) -> None:
|
||||
"""Check that an event complies with the auth rules that depend on room
|
||||
state.
|
||||
|
||||
Raises:
|
||||
AuthError (or UnstableSpecAuthError) if the event is not allowed.
|
||||
SynapseError if the event or its auth events are malformed.
|
||||
"""
|
||||
|
||||
def auth_types_for_event(
|
||||
room_version: RoomVersion, event: Event | "EventBuilder"
|
||||
) -> set[StateKey]:
|
||||
"""The (type, state_key) pairs that may be needed to auth the event."""
|
||||
@@ -178,10 +178,6 @@ class TestJoinsLimitedByPerRoomRateLimiter(FederatingHomeserverTestCase):
|
||||
"send_join",
|
||||
mock_send_join,
|
||||
),
|
||||
patch(
|
||||
"synapse.event_auth._is_membership_change_allowed",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"synapse.handlers.federation_event.check_state_dependent_auth_rules",
|
||||
return_value=None,
|
||||
|
||||
@@ -1467,10 +1467,6 @@ class GetCurrentStateDeltaMembershipChangesForUserFederationTestCase(
|
||||
"send_join",
|
||||
mock_send_join,
|
||||
),
|
||||
patch(
|
||||
"synapse.event_auth._is_membership_change_allowed",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"synapse.handlers.federation_event.check_state_dependent_auth_rules",
|
||||
return_value=None,
|
||||
|
||||
+47
-11
@@ -891,6 +891,37 @@ class EventAuthTestCase(unittest.TestCase):
|
||||
auth_events.values(),
|
||||
)
|
||||
|
||||
def test_auth_types_for_event_iteration_order(self) -> None:
|
||||
"""`auth_types_for_event` returns a set that iterates in the same order as
|
||||
the set the Python implementation built.
|
||||
|
||||
Event creation lists the auth events in this order, so it feeds into the
|
||||
event ID: two otherwise identical events must get the same ID.
|
||||
"""
|
||||
creator = "@creator:example.com"
|
||||
joiner = "@joiner:example.com"
|
||||
token = "abc"
|
||||
event = _member_event(
|
||||
RoomVersions.V10,
|
||||
joiner,
|
||||
"invite",
|
||||
sender=creator,
|
||||
additional_content={"third_party_invite": {"signed": {"token": token}}},
|
||||
)
|
||||
|
||||
# Built the way the Python implementation did.
|
||||
expected = {
|
||||
("m.room.power_levels", ""),
|
||||
("m.room.member", creator),
|
||||
("m.room.create", ""),
|
||||
}
|
||||
expected.add(("m.room.join_rules", ""))
|
||||
expected.add(("m.room.member", joiner))
|
||||
expected.add(("m.room.third_party_invite", token))
|
||||
|
||||
auth_types = event_auth.auth_types_for_event(RoomVersions.V10, event)
|
||||
self.assertEqual(list(auth_types), list(expected))
|
||||
|
||||
def test_room_v10_rejects_string_power_levels(self) -> None:
|
||||
pl_event_content = {"users_default": "42"}
|
||||
pl_event = make_test_event(
|
||||
@@ -920,15 +951,22 @@ class EventAuthTestCase(unittest.TestCase):
|
||||
room_version=RoomVersions.V10,
|
||||
)
|
||||
|
||||
with self.assertRaises(SynapseError):
|
||||
event_auth._check_power_levels(
|
||||
pl_event.room_version, pl_event, {("fake_type", "fake_key"): pl_event2}
|
||||
)
|
||||
self._assert_invalid_power_levels(pl_event)
|
||||
self._assert_invalid_power_levels(pl_event2)
|
||||
|
||||
with self.assertRaises(SynapseError):
|
||||
event_auth._check_power_levels(
|
||||
pl_event.room_version, pl_event2, {("fake_type", "fake_key"): pl_event}
|
||||
)
|
||||
def _assert_invalid_power_levels(self, pl_event: EventBase) -> None:
|
||||
"""Assert that auth fails `pl_event` with a `SynapseError` that is not an
|
||||
`AuthError`, i.e. that the power levels are malformed rather than merely
|
||||
not allowed. The sender is the room creator, so nothing else can fail.
|
||||
"""
|
||||
sender = pl_event.sender
|
||||
auth_events = [
|
||||
_create_event(pl_event.room_version, sender),
|
||||
_join_event(pl_event.room_version, sender),
|
||||
]
|
||||
with self.assertRaises(SynapseError) as cm:
|
||||
event_auth.check_state_dependent_auth_rules(pl_event, auth_events)
|
||||
self.assertNotIsInstance(cm.exception, AuthError)
|
||||
|
||||
def test_room_v10_rejects_other_non_integer_power_levels(self) -> None:
|
||||
"""We should reject PLs that are non-integer, non-string JSON values.
|
||||
@@ -956,9 +994,7 @@ class EventAuthTestCase(unittest.TestCase):
|
||||
{"users_default": {}},
|
||||
]
|
||||
for content in contents:
|
||||
event = create_event(content)
|
||||
with self.assertRaises(SynapseError):
|
||||
event_auth._check_power_levels(event.room_version, event, {})
|
||||
self._assert_invalid_power_levels(create_event(content))
|
||||
|
||||
def test_event_exists_in_state_dag(self) -> None:
|
||||
events_that_exist_in_state_dag = [
|
||||
|
||||
Reference in new issue
Block a user