diff --git a/clientapi/jsonerror/jsonerror.go b/clientapi/jsonerror/jsonerror.go index 1fc1c0c01..70bac61dc 100644 --- a/clientapi/jsonerror/jsonerror.go +++ b/clientapi/jsonerror/jsonerror.go @@ -154,6 +154,12 @@ func MissingParam(msg string) *MatrixError { return &MatrixError{"M_MISSING_PARAM", msg} } +// UnableToAuthoriseJoin is an error that is returned when a server can't +// determine whether to allow a restricted join or not. +func UnableToAuthoriseJoin(msg string) *MatrixError { + return &MatrixError{"M_UNABLE_TO_AUTHORISE_JOIN", msg} +} + // LeaveServerNoticeError is an error returned when trying to reject an invite // for a server notice room. func LeaveServerNoticeError() *MatrixError { diff --git a/federationapi/internal/perform.go b/federationapi/internal/perform.go index c176a6ea8..970a64653 100644 --- a/federationapi/internal/perform.go +++ b/federationapi/internal/perform.go @@ -210,10 +210,18 @@ func (r *FederationInternalAPI) performJoinUsingServer( } r.statistics.ForServer(serverName).Success() - authEvents := respSendJoin.AuthEvents.UntrustedEvents(respMakeJoin.RoomVersion) + // If the remote server returned an event in the "event" key of + // the send_join request then we should use that instead. It may + // contain signatures that we don't know about. + if respSendJoin.Event != nil && isWellFormedMembershipEvent( + respSendJoin.Event, roomID, userID, r.cfg.Matrix.ServerName, + ) { + event = respSendJoin.Event + } // Sanity-check the join response to ensure that it has a create // event, that the room version is known, etc. + authEvents := respSendJoin.AuthEvents.UntrustedEvents(respMakeJoin.RoomVersion) if err = sanityCheckAuthChain(authEvents); err != nil { return fmt.Errorf("sanityCheckAuthChain: %w", err) } @@ -271,6 +279,26 @@ func (r *FederationInternalAPI) performJoinUsingServer( return nil } +// isWellFormedMembershipEvent returns true if the event looks like a legitimate +// membership event. +func isWellFormedMembershipEvent(event *gomatrixserverlib.Event, roomID, userID string, origin gomatrixserverlib.ServerName) bool { + if membership, err := event.Membership(); err != nil { + return false + } else if membership != gomatrixserverlib.Join { + return false + } + if event.RoomID() != roomID { + return false + } + if event.Origin() != origin { + return false + } + if !event.StateKeyEquals(userID) { + return false + } + return true +} + // PerformOutboundPeekRequest implements api.FederationInternalAPI func (r *FederationInternalAPI) PerformOutboundPeek( ctx context.Context, diff --git a/federationapi/routing/join.go b/federationapi/routing/join.go index 767699728..9f8d94105 100644 --- a/federationapi/routing/join.go +++ b/federationapi/routing/join.go @@ -15,6 +15,7 @@ package routing import ( + "encoding/json" "fmt" "net/http" "sort" @@ -103,6 +104,16 @@ func MakeJoin( } } + // Check if the restricted join is allowed. If the room doesn't + // support restricted joins then this is effectively a no-op. + res, authorisedVia, err := checkRestrictedJoin(httpReq, rsAPI, verRes.RoomVersion, roomID, userID) + if err != nil { + util.GetLogger(httpReq.Context()).WithError(err).Error("checkRestrictedJoin failed") + return jsonerror.InternalServerError() + } else if res != nil { + return *res + } + // Try building an event for the server builder := gomatrixserverlib.EventBuilder{ Sender: userID, @@ -110,8 +121,11 @@ func MakeJoin( Type: "m.room.member", StateKey: &userID, } - err = builder.SetContent(map[string]interface{}{"membership": gomatrixserverlib.Join}) - if err != nil { + content := gomatrixserverlib.MemberContent{ + Membership: gomatrixserverlib.Join, + AuthorisedVia: authorisedVia, + } + if err = builder.SetContent(content); err != nil { util.GetLogger(httpReq.Context()).WithError(err).Error("builder.SetContent failed") return jsonerror.InternalServerError() } @@ -161,6 +175,7 @@ func MakeJoin( // SendJoin implements the /send_join API // The make-join send-join dance makes much more sense as a single // flow so the cyclomatic complexity is high: +// nolint:gocyclo func SendJoin( httpReq *http.Request, request *gomatrixserverlib.FederationRequest, @@ -314,6 +329,40 @@ func SendJoin( } } + // If the membership content contains a user ID for a server that is not + // ours then we should kick it back. + var memberContent gomatrixserverlib.MemberContent + if err := json.Unmarshal(event.Content(), &memberContent); err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(err.Error()), + } + } + if memberContent.AuthorisedVia != "" { + _, domain, err := gomatrixserverlib.SplitID('@', memberContent.AuthorisedVia) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(fmt.Sprintf("The authorising username %q is invalid.", memberContent.AuthorisedVia)), + } + } + if domain != cfg.Matrix.ServerName { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(fmt.Sprintf("The authorising username %q does not belong to this server.", memberContent.AuthorisedVia)), + } + } + } + + // Sign the membership event. This is required for restricted joins to work + // in the case that the authorised via user is one of our own users. It also + // doesn't hurt to do it even if it isn't a restricted join. + signed := event.Sign( + string(cfg.Matrix.ServerName), + cfg.Matrix.KeyID, + cfg.Matrix.PrivateKey, + ) + // Send the events to the room server. // We are responsible for notifying other servers that the user has joined // the room, so set SendAsServer to cfg.Matrix.ServerName @@ -323,7 +372,7 @@ func SendJoin( InputRoomEvents: []api.InputRoomEvent{ { Kind: api.KindNew, - Event: event.Headered(stateAndAuthChainResponse.RoomVersion), + Event: signed.Headered(stateAndAuthChainResponse.RoomVersion), SendAsServer: string(cfg.Matrix.ServerName), TransactionID: nil, }, @@ -354,10 +403,77 @@ func SendJoin( StateEvents: gomatrixserverlib.NewEventJSONsFromHeaderedEvents(stateAndAuthChainResponse.StateEvents), AuthEvents: gomatrixserverlib.NewEventJSONsFromHeaderedEvents(stateAndAuthChainResponse.AuthChainEvents), Origin: cfg.Matrix.ServerName, + Event: &signed, }, } } +// checkRestrictedJoin finds out whether or not we can assist in processing +// a restricted room join. If the room version does not support restricted +// joins then this function returns with no side effects. This returns three +// values: +// * an optional JSON response body (i.e. M_UNABLE_TO_AUTHORISE_JOIN) which +// should always be sent back to the client if one is specified +// * a user ID of an authorising user, typically a user that has power to +// issue invites in the room, if one has been found +// * an error if there was a problem finding out if this was allowable, +// like if the room version isn't known or a problem happened talking to +// the roomserver +func checkRestrictedJoin( + httpReq *http.Request, + rsAPI api.FederationRoomserverAPI, + roomVersion gomatrixserverlib.RoomVersion, + roomID, userID string, +) (*util.JSONResponse, string, error) { + if allowRestricted, err := roomVersion.AllowRestrictedJoinsInEventAuth(); err != nil { + return nil, "", err + } else if !allowRestricted { + return nil, "", nil + } + req := &api.QueryRestrictedJoinAllowedRequest{ + RoomID: roomID, + UserID: userID, + } + res := &api.QueryRestrictedJoinAllowedResponse{} + if err := rsAPI.QueryRestrictedJoinAllowed(httpReq.Context(), req, res); err != nil { + return nil, "", err + } + + switch { + case !res.Restricted: + // The join rules for the room don't restrict membership. + return nil, "", nil + + case !res.Resident: + // The join rules restrict membership but our server isn't currently + // joined to all of the allowed rooms, so we can't actually decide + // whether or not to allow the user to join. This error code should + // tell the joining server to try joining via another resident server + // instead. + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.UnableToAuthoriseJoin("This server cannot authorise the join."), + }, "", nil + + case !res.Allowed: + // The join rules restrict membership, our server is in the relevant + // rooms and the user wasn't joined to join any of the allowed rooms + // and therefore can't join this room. + return &util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("You are not joined to any matching rooms."), + }, "", nil + + default: + // The join rules restrict membership, our server is in the relevant + // rooms and the user was allowed to join because they belong to one + // of the allowed rooms. We now need to pick one of our own local users + // from within the room to use as the authorising user ID, so that it + // can be referred to from within the membership content. + return nil, res.AuthorisedVia, nil + } +} + type eventsByDepth []*gomatrixserverlib.HeaderedEvent func (e eventsByDepth) Len() int { diff --git a/roomserver/api/api.go b/roomserver/api/api.go index 80e7aed64..f87ff2962 100644 --- a/roomserver/api/api.go +++ b/roomserver/api/api.go @@ -184,6 +184,7 @@ type FederationRoomserverAPI interface { // Query whether a server is allowed to see an event QueryServerAllowedToSeeEvent(ctx context.Context, req *QueryServerAllowedToSeeEventRequest, res *QueryServerAllowedToSeeEventResponse) error QueryRoomsForUser(ctx context.Context, req *QueryRoomsForUserRequest, res *QueryRoomsForUserResponse) error + QueryRestrictedJoinAllowed(ctx context.Context, req *QueryRestrictedJoinAllowedRequest, res *QueryRestrictedJoinAllowedResponse) error PerformInboundPeek(ctx context.Context, req *PerformInboundPeekRequest, res *PerformInboundPeekResponse) error PerformInvite(ctx context.Context, req *PerformInviteRequest, res *PerformInviteResponse) error // Query a given amount (or less) of events prior to a given set of events. diff --git a/roomserver/api/api_trace.go b/roomserver/api/api_trace.go index 711324644..92c5c1b1d 100644 --- a/roomserver/api/api_trace.go +++ b/roomserver/api/api_trace.go @@ -354,6 +354,16 @@ func (t *RoomserverInternalAPITrace) QueryAuthChain( return err } +func (t *RoomserverInternalAPITrace) QueryRestrictedJoinAllowed( + ctx context.Context, + request *QueryRestrictedJoinAllowedRequest, + response *QueryRestrictedJoinAllowedResponse, +) error { + err := t.Impl.QueryRestrictedJoinAllowed(ctx, request, response) + util.GetLogger(ctx).WithError(err).Infof("QueryRestrictedJoinAllowed req=%+v res=%+v", js(request), js(response)) + return err +} + func js(thing interface{}) string { b, err := json.Marshal(thing) if err != nil { diff --git a/roomserver/api/query.go b/roomserver/api/query.go index afafb87c3..f157a9025 100644 --- a/roomserver/api/query.go +++ b/roomserver/api/query.go @@ -348,6 +348,26 @@ type QueryServerBannedFromRoomResponse struct { Banned bool `json:"banned"` } +type QueryRestrictedJoinAllowedRequest struct { + UserID string `json:"user_id"` + RoomID string `json:"room_id"` +} + +type QueryRestrictedJoinAllowedResponse struct { + // True if the room membership is restricted by the join rule being set to "restricted" + Restricted bool `json:"restricted"` + // True if our local server is joined to all of the allowed rooms specified in the "allow" + // key of the join rule, false if we are missing from some of them and therefore can't + // reliably decide whether or not we can satisfy the join + Resident bool `json:"resident"` + // True if the restricted join is allowed because we found the membership in one of the + // allowed rooms from the join rule, false if not + Allowed bool `json:"allowed"` + // Contains the user ID of the selected user ID that has power to issue invites, this will + // get populated into the "join_authorised_via_users_server" content in the membership + AuthorisedVia string `json:"authorised_via,omitempty"` +} + // MarshalJSON stringifies the room ID and StateKeyTuple keys so they can be sent over the wire in HTTP API mode. func (r *QueryBulkStateContentResponse) MarshalJSON() ([]byte, error) { se := make(map[string]string) diff --git a/roomserver/internal/perform/perform_join.go b/roomserver/internal/perform/perform_join.go index 61a0206ef..c9e839198 100644 --- a/roomserver/internal/perform/perform_join.go +++ b/roomserver/internal/perform/perform_join.go @@ -24,6 +24,7 @@ import ( "github.com/getsentry/sentry-go" fsAPI "github.com/matrix-org/dendrite/federationapi/api" "github.com/matrix-org/dendrite/internal/eventutil" + "github.com/matrix-org/dendrite/roomserver/api" rsAPI "github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/dendrite/roomserver/internal/helpers" "github.com/matrix-org/dendrite/roomserver/internal/input" @@ -160,6 +161,7 @@ func (r *Joiner) performJoinRoomByAlias( } // TODO: Break this function up a bit +// nolint:gocyclo func (r *Joiner) performJoinRoomByID( ctx context.Context, req *rsAPI.PerformJoinRequest, @@ -210,6 +212,11 @@ func (r *Joiner) performJoinRoomByID( req.Content = map[string]interface{}{} } req.Content["membership"] = gomatrixserverlib.Join + if authorisedVia, aerr := r.populateAuthorisedViaUserForRestrictedJoin(ctx, req); aerr != nil { + return "", "", aerr + } else if authorisedVia != "" { + req.Content["join_authorised_via_users_server"] = authorisedVia + } if err = eb.SetContent(req.Content); err != nil { return "", "", fmt.Errorf("eb.SetContent: %w", err) } @@ -350,6 +357,33 @@ func (r *Joiner) performFederatedJoinRoomByID( return fedRes.JoinedVia, nil } +func (r *Joiner) populateAuthorisedViaUserForRestrictedJoin( + ctx context.Context, + joinReq *rsAPI.PerformJoinRequest, +) (string, error) { + req := &api.QueryRestrictedJoinAllowedRequest{ + UserID: joinReq.UserID, + RoomID: joinReq.RoomIDOrAlias, + } + res := &api.QueryRestrictedJoinAllowedResponse{} + if err := r.Queryer.QueryRestrictedJoinAllowed(ctx, req, res); err != nil { + return "", fmt.Errorf("r.Queryer.QueryRestrictedJoinAllowed: %w", err) + } + if !res.Restricted { + return "", nil + } + if !res.Resident { + return "", nil + } + if !res.Allowed { + return "", &rsAPI.PerformError{ + Code: rsAPI.PerformErrorNotAllowed, + Msg: fmt.Sprintf("The join to room %s was not allowed.", joinReq.RoomIDOrAlias), + } + } + return res.AuthorisedVia, nil +} + func buildEvent( ctx context.Context, db storage.Database, cfg *config.Global, builder *gomatrixserverlib.EventBuilder, ) (*gomatrixserverlib.HeaderedEvent, *rsAPI.QueryLatestEventsAndStateResponse, error) { diff --git a/roomserver/internal/query/query.go b/roomserver/internal/query/query.go index d25bdc378..70613baab 100644 --- a/roomserver/internal/query/query.go +++ b/roomserver/internal/query/query.go @@ -16,6 +16,7 @@ package query import ( "context" + "encoding/json" "errors" "fmt" @@ -757,3 +758,128 @@ func (r *Queryer) QueryAuthChain(ctx context.Context, req *api.QueryAuthChainReq res.AuthChain = hchain return nil } + +// nolint:gocyclo +func (r *Queryer) QueryRestrictedJoinAllowed(ctx context.Context, req *api.QueryRestrictedJoinAllowedRequest, res *api.QueryRestrictedJoinAllowedResponse) error { + // Look up if we know anything about the room. If it doesn't exist + // or is a stub entry then we can't do anything. + roomInfo, err := r.DB.RoomInfo(ctx, req.RoomID) + if err != nil { + return fmt.Errorf("r.DB.RoomInfo: %w", err) + } + if roomInfo == nil || roomInfo.IsStub { + return nil // fmt.Errorf("room %q doesn't exist or is stub room", req.RoomID) + } + // If the room version doesn't allow restricted joins then don't + // try to process any further. + allowRestrictedJoins, err := roomInfo.RoomVersion.AllowRestrictedJoinsInEventAuth() + if err != nil { + return fmt.Errorf("roomInfo.RoomVersion.AllowRestrictedJoinsInEventAuth: %w", err) + } else if !allowRestrictedJoins { + return nil + } + // Get the join rules to work out if the join rule is "restricted". + joinRulesEvent, err := r.DB.GetStateEvent(ctx, req.RoomID, gomatrixserverlib.MRoomJoinRules, "") + if err != nil { + return fmt.Errorf("r.DB.GetStateEvent: %w", err) + } + var joinRules gomatrixserverlib.JoinRuleContent + if err = json.Unmarshal(joinRulesEvent.Content(), &joinRules); err != nil { + return fmt.Errorf("json.Unmarshal: %w", err) + } + // If the join rule isn't "restricted" then there's nothing more to do. + res.Restricted = joinRules.JoinRule == gomatrixserverlib.Restricted + if !res.Restricted { + return nil + } + // Start off by populating the "resident" flag in the response. If we + // come across any rooms in the request that are missing, we will unset + // the flag. + res.Resident = true + // If the user is already invited to the room then the join is allowed + // but we don't specify an authorised via user, since the event auth + // will allow the join anyway. + var pending bool + if pending, _, _, err = helpers.IsInvitePending(ctx, r.DB, req.RoomID, req.UserID); err != nil { + return fmt.Errorf("helpers.IsInvitePending: %w", err) + } else if pending { + res.Allowed = true + return nil + } + // We need to get the power levels content so that we can determine which + // users in the room are entitled to issue invites. We need to use one of + // these users as the authorising user. + powerLevelsEvent, err := r.DB.GetStateEvent(ctx, req.RoomID, gomatrixserverlib.MRoomPowerLevels, "") + if err != nil { + return fmt.Errorf("r.DB.GetStateEvent: %w", err) + } + var powerLevels gomatrixserverlib.PowerLevelContent + if err = json.Unmarshal(powerLevelsEvent.Content(), &powerLevels); err != nil { + return fmt.Errorf("json.Unmarshal: %w", err) + } + // Step through the join rules and see if the user matches any of them. + for _, rule := range joinRules.Allow { + // We only understand "m.room_membership" rules at this point in + // time, so skip any rule that doesn't match those. + if rule.Type != gomatrixserverlib.MRoomMembership { + continue + } + // See if the room exists. If it doesn't exist or if it's a stub + // room entry then we can't check memberships. + targetRoomInfo, err := r.DB.RoomInfo(ctx, rule.RoomID) + if err != nil || targetRoomInfo == nil || targetRoomInfo.IsStub { + res.Resident = false + continue + } + // First of all work out if *we* are still in the room, otherwise + // it's possible that the memberships will be out of date. + isIn, err := r.DB.GetLocalServerInRoom(ctx, targetRoomInfo.RoomNID) + if err != nil || !isIn { + // If we aren't in the room, we can no longer tell if the room + // memberships are up-to-date. + res.Resident = false + continue + } + // At this point we're happy that we are in the room, so now let's + // see if the target user is in the room. + _, isIn, _, err = r.DB.GetMembership(ctx, targetRoomInfo.RoomNID, req.UserID) + if err != nil { + continue + } + // If the user is not in the room then we will skip them. + if !isIn { + continue + } + // The user is in the room, so now we will need to authorise the + // join using the user ID of one of our own users in the room. Pick + // one. + joinNIDs, err := r.DB.GetMembershipEventNIDsForRoom(ctx, targetRoomInfo.RoomNID, true, true) + if err != nil || len(joinNIDs) == 0 { + // There should always be more than one join NID at this point + // because we are gated behind GetLocalServerInRoom, but y'know, + // sometimes strange things happen. + continue + } + // For each of the joined users, let's see if we can get a valid + // membership event. + for _, joinNID := range joinNIDs { + events, err := r.DB.Events(ctx, []types.EventNID{joinNID}) + if err != nil || len(events) != 1 { + continue + } + event := events[0] + if event.Type() != gomatrixserverlib.MRoomMember || event.StateKey() == nil { + continue // shouldn't happen + } + // Only users that have the power to invite should be chosen. + if powerLevels.UserLevel(*event.StateKey()) < powerLevels.Invite { + continue + } + res.Resident = true + res.Allowed = true + res.AuthorisedVia = *event.StateKey() + return nil + } + } + return nil +} diff --git a/roomserver/inthttp/client.go b/roomserver/inthttp/client.go index 09358001b..7b10ae657 100644 --- a/roomserver/inthttp/client.go +++ b/roomserver/inthttp/client.go @@ -61,6 +61,7 @@ const ( RoomserverQueryKnownUsersPath = "/roomserver/queryKnownUsers" RoomserverQueryServerBannedFromRoomPath = "/roomserver/queryServerBannedFromRoom" RoomserverQueryAuthChainPath = "/roomserver/queryAuthChain" + RoomserverQueryRestrictedJoinAllowed = "/roomserver/queryRestrictedJoinAllowed" ) type httpRoomserverInternalAPI struct { @@ -557,6 +558,16 @@ func (h *httpRoomserverInternalAPI) QueryServerBannedFromRoom( return httputil.PostJSON(ctx, span, h.httpClient, apiURL, req, res) } +func (h *httpRoomserverInternalAPI) QueryRestrictedJoinAllowed( + ctx context.Context, req *api.QueryRestrictedJoinAllowedRequest, res *api.QueryRestrictedJoinAllowedResponse, +) error { + span, ctx := opentracing.StartSpanFromContext(ctx, "QueryRestrictedJoinAllowed") + defer span.Finish() + + apiURL := h.roomserverURL + RoomserverQueryRestrictedJoinAllowed + return httputil.PostJSON(ctx, span, h.httpClient, apiURL, req, res) +} + func (h *httpRoomserverInternalAPI) PerformForget(ctx context.Context, req *api.PerformForgetRequest, res *api.PerformForgetResponse) error { span, ctx := opentracing.StartSpanFromContext(ctx, "PerformForget") defer span.Finish() diff --git a/roomserver/inthttp/server.go b/roomserver/inthttp/server.go index 9042e341b..ad4fdc469 100644 --- a/roomserver/inthttp/server.go +++ b/roomserver/inthttp/server.go @@ -472,4 +472,17 @@ func AddRoutes(r api.RoomserverInternalAPI, internalAPIMux *mux.Router) { return util.JSONResponse{Code: http.StatusOK, JSON: &response} }), ) + internalAPIMux.Handle(RoomserverQueryRestrictedJoinAllowed, + httputil.MakeInternalAPI("queryRestrictedJoinAllowed", func(req *http.Request) util.JSONResponse { + request := api.QueryRestrictedJoinAllowedRequest{} + response := api.QueryRestrictedJoinAllowedResponse{} + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.MessageResponse(http.StatusBadRequest, err.Error()) + } + if err := r.QueryRestrictedJoinAllowed(req.Context(), &request, &response); err != nil { + return util.ErrorResponse(err) + } + return util.JSONResponse{Code: http.StatusOK, JSON: &response} + }), + ) }