%%%------------------------------------------------------------------- %%% File : mod_privilege.erl %%% Author : Anna Mukharram %%% Purpose : XEP-0356: Privileged Entity %%% %%% %%% ejabberd, Copyright (C) 2002-2024 ProcessOne %%% %%% This program is free software; you can redistribute it and/or %%% modify it under the terms of the GNU General Public License as %%% published by the Free Software Foundation; either version 2 of the %%% License, or (at your option) any later version. %%% %%% This program is distributed in the hope that it will be useful, %%% but WITHOUT ANY WARRANTY; without even the implied warranty of %%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU %%% General Public License for more details. %%% %%% You should have received a copy of the GNU General Public License along %%% with this program; if not, write to the Free Software Foundation, Inc., %%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. %%% %%%------------------------------------------------------------------- -module(mod_privilege). -author('amuhar3@gmail.com'). -protocol({xep, 356, '0.4.1', '24.10', "complete", ""}). -behaviour(gen_server). -behaviour(gen_mod). %% API -export([start/2, stop/1, reload/3, mod_opt_type/1, mod_options/1, depends/2]). -export([mod_doc/0]). %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -export([component_connected/1, component_disconnected/2, component_send_packet/1, roster_access/2, process_message/1, process_presence_out/1, process_presence_in/1]). -include("logger.hrl"). -include_lib("xmpp/include/xmpp.hrl"). -include("translate.hrl"). -type roster_permission() :: both | get | set. -type iq_permission() :: both | get | set. -type presence_permission() :: managed_entity | roster. -type message_permission() :: outgoing. -type roster_permissions() :: [{roster_permission(), acl:acl()}]. -type iq_permissions() :: [{iq_permission(), acl:acl()}]. -type presence_permissions() :: [{presence_permission(), acl:acl()}]. -type message_permissions() :: [{message_permission(), acl:acl()}]. -type access() :: [{roster, roster_permission()} | {iq, [privilege_namespace()]} | {presence, presence_permission()} | {message, message_permission()}]. -type permissions() :: #{binary() => access()}. -record(state, {server_host = <<"">> :: binary()}). %%%=================================================================== %%% API %%%=================================================================== start(Host, Opts) -> gen_mod:start_child(?MODULE, Host, Opts). stop(Host) -> gen_mod:stop_child(?MODULE, Host). reload(_Host, _NewOpts, _OldOpts) -> ok. mod_opt_type(roster) -> econf:options( #{both => econf:acl(), get => econf:acl(), set => econf:acl()}); mod_opt_type(iq) -> econf:map( econf:binary(), econf:options(#{both => econf:acl(), get => econf:acl(), set => econf:acl()})); mod_opt_type(message) -> econf:options( #{outgoing => econf:acl()}); mod_opt_type(presence) -> econf:options( #{managed_entity => econf:acl(), roster => econf:acl()}). mod_options(_) -> [{roster, [{both, none}, {get, none}, {set, none}]}, {iq, []}, {presence, [{managed_entity, none}, {roster, none}]}, {message, [{outgoing,none}]}]. mod_doc() -> #{desc => [?T("This module is an implementation of " "https://xmpp.org/extensions/xep-0356.html" "[XEP-0356: Privileged Entity]. This extension " "allows components to have privileged access to " "other entity data (send messages on behalf of the " "server or on behalf of a user, get/set user roster, " "access presence information, etc.). This may be used " "to write powerful external components, for example " "implementing an external " "https://xmpp.org/extensions/xep-0163.html[PEP] or " "https://xmpp.org/extensions/xep-0313.html[MAM] service."), "", ?T("By default a component does not have any privileged access. " "It is worth noting that the permissions grant access to " "the component to a specific data type for all users of " "the virtual host on which 'mod_privilege' is loaded."), "", ?T("Make sure you have a listener configured to connect your " "component. Check the section about listening ports for more " "information."), "", ?T("WARNING: Security issue: Privileged access gives components " "access to sensitive data, so permission should be granted " "carefully, only if you trust a component."), "", ?T("NOTE: This module is complementary to _`mod_delegation`_, " "but can also be used separately.")], note => "improved in 24.10", opts => [{roster, #{value => ?T("Options"), desc => ?T("This option defines roster permissions. " "By default no permissions are given. " "The 'Options' are:")}, [{both, #{value => ?T("AccessName"), desc => ?T("Sets read/write access to a user's roster. " "The default value is 'none'.")}}, {get, #{value => ?T("AccessName"), desc => ?T("Sets read access to a user's roster. " "The default value is 'none'.")}}, {set, #{value => ?T("AccessName"), desc => ?T("Sets write access to a user's roster. " "The default value is 'none'.")}}]}, {iq, #{value => "{Namespace: Options}", desc => ?T("This option defines namespaces and their IQ permissions. " "By default no permissions are given. " "The 'Options' are:")}, [{both, #{value => ?T("AccessName"), desc => ?T("Allows sending IQ stanzas of type 'get' and 'set'. " "The default value is 'none'.")}}, {get, #{value => ?T("AccessName"), desc => ?T("Allows sending IQ stanzas of type 'get'. " "The default value is 'none'.")}}, {set, #{value => ?T("AccessName"), desc => ?T("Allows sending IQ stanzas of type 'set'. " "The default value is 'none'.")}}]}, {message, #{value => ?T("Options"), desc => ?T("This option defines permissions for messages. " "By default no permissions are given. " "The 'Options' are:")}, [{outgoing, #{value => ?T("AccessName"), desc => ?T("The option defines an access rule for sending " "outgoing messages by the component. " "The default value is 'none'.")}}]}, {presence, #{value => ?T("Options"), desc => ?T("This option defines permissions for presences. " "By default no permissions are given. " "The 'Options' are:")}, [{managed_entity, #{value => ?T("AccessName"), desc => ?T("An access rule that gives permissions to " "the component to receive server presences. " "The default value is 'none'.")}}, {roster, #{value => ?T("AccessName"), desc => ?T("An access rule that gives permissions to " "the component to receive the presence of both " "the users and the contacts in their roster. " "The default value is 'none'.")}}]}], example => ["modules:", " mod_privilege:", " iq:", " http://jabber.org/protocol/pubsub:", " get: all", " roster:", " get: all", " presence:", " managed_entity: all", " message:", " outgoing: all"]}. depends(_, _) -> []. -spec component_connected(binary()) -> ok. component_connected(Host) -> lists:foreach( fun(ServerHost) -> Proc = gen_mod:get_module_proc(ServerHost, ?MODULE), gen_server:cast(Proc, {component_connected, Host}) end, ejabberd_option:hosts()). -spec component_disconnected(binary(), binary()) -> ok. component_disconnected(Host, _Reason) -> lists:foreach( fun(ServerHost) -> Proc = gen_mod:get_module_proc(ServerHost, ?MODULE), gen_server:cast(Proc, {component_disconnected, Host}) end, ejabberd_option:hosts()). %% %% Message processing %% -spec process_message(stanza()) -> stop | ok. process_message(#message{from = #jid{luser = <<"">>, lresource = <<"">>} = From, to = #jid{lresource = <<"">>} = To, lang = Lang, type = T} = Msg) when T /= error -> Host = From#jid.lserver, ServerHost = To#jid.lserver, Permissions = get_permissions(ServerHost), case maps:find(Host, Permissions) of {ok, Access} -> case proplists:get_value(message, Access, none) of outgoing -> forward_message(Msg); _ -> Txt = ?T("Insufficient privilege"), Err = xmpp:err_forbidden(Txt, Lang), ejabberd_router:route_error(Msg, Err) end, stop; error -> %% Component is disconnected ok end; process_message(_Stanza) -> ok. %% %% IQ processing %% %% @format-begin component_send_packet({#iq{from = From, to = #jid{lresource = <<"">>} = To, id = Id, type = Type} = IQ, State}) when Type /= error -> Host = From#jid.lserver, ServerHost = To#jid.lserver, Permissions = get_permissions(ServerHost), Result = case {maps:find(Host, Permissions), get_iq_encapsulated_details(IQ)} of {{ok, Access}, {ok, EncapType, EncapNs, EncapFrom, EncIq}} when (EncapType == Type) and ((EncapFrom == undefined) or (EncapFrom == To)) -> NsPermissions = proplists:get_value(iq, Access, []), Permission = case lists:keyfind(EncapNs, 2, NsPermissions) of #privilege_namespace{type = AllowedType} -> AllowedType; _ -> none end, case Permission == both orelse Permission == get andalso Type == get orelse Permission == set andalso Type == set of true -> forward_iq(Host, To, Id, EncIq); false -> ?INFO_MSG("IQ not forwarded: Permission not granted to ns=~s with type=~p", [EncapNs, Type]), drop end; {error, _} -> %% Component is disconnected ?INFO_MSG("IQ not forwarded: Component seems disconnected", []), drop; {_, {ok, E, _, _, _}} when E /= Type -> ?INFO_MSG("IQ not forwarded: The encapsulated IQ stanza type=~p " "does not match the top-level IQ stanza type=~p", [E, Type]), drop; {_, {ok, _, _, EF, _}} when (EF /= undefined) and (EF /= To) -> ?INFO_MSG("IQ not forwarded: The FROM attribute in the encapsulated " "IQ stanza and the TO in top-level IQ stanza do not match", []), drop; {_, {error, no_privileged_iq, _Err}} -> ?INFO_MSG("IQ not forwarded: Component tried to send not wrapped IQ stanza.", []), drop; {_, {error, roster_query, _Err}} -> IQ; {_, {error, ErrType, _Err}} -> ?INFO_MSG("IQ not forwarded: Component tried to send not valid IQ stanza: ~p.", [ErrType]), drop end, {Result, State}; component_send_packet(Acc) -> Acc. %% @format-end %% %% Roster processing %% -spec roster_access({true, iq()} | false, iq()) -> {true, iq()} | false. roster_access({true, _IQ} = Acc, _) -> Acc; roster_access(false, #iq{from = From, to = To, type = Type} = IQ) -> Host = From#jid.lserver, ServerHost = To#jid.lserver, Permissions = get_permissions(ServerHost), case maps:find(Host, Permissions) of {ok, Access} -> Permission = proplists:get_value(roster, Access, none), case (Permission == both) orelse (Permission == get andalso Type == get) orelse (Permission == set andalso Type == set) of true -> {true, xmpp:put_meta(IQ, privilege_from, To)}; false -> false end; error -> %% Component is disconnected false end. -spec process_presence_out({stanza(), ejabberd_c2s:state()}) -> {stanza(), ejabberd_c2s:state()}. process_presence_out({#presence{ from = #jid{luser = LUser, lserver = LServer} = From, to = #jid{luser = LUser, lserver = LServer, lresource = <<"">>}, type = Type} = Pres, C2SState}) when Type == available; Type == unavailable -> %% Self-presence processing Permissions = get_permissions(LServer), lists:foreach( fun({Host, Access}) -> Permission = proplists:get_value(presence, Access, none), if Permission == roster; Permission == managed_entity -> To = jid:make(Host), ejabberd_router:route( xmpp:set_from_to(Pres, From, To)); true -> ok end end, maps:to_list(Permissions)), {Pres, C2SState}; process_presence_out(Acc) -> Acc. -spec process_presence_in({stanza(), ejabberd_c2s:state()}) -> {stanza(), ejabberd_c2s:state()}. process_presence_in({#presence{ from = #jid{luser = U, lserver = S} = From, to = #jid{luser = LUser, lserver = LServer}, type = Type} = Pres, C2SState}) when {U, S} /= {LUser, LServer} andalso (Type == available orelse Type == unavailable) -> Permissions = get_permissions(LServer), lists:foreach( fun({Host, Access}) -> case proplists:get_value(presence, Access, none) of roster -> Permission = proplists:get_value(roster, Access, none), if Permission == both; Permission == get -> To = jid:make(Host), ejabberd_router:route( xmpp:set_from_to(Pres, From, To)); true -> ok end; _ -> ok end end, maps:to_list(Permissions)), {Pres, C2SState}; process_presence_in(Acc) -> Acc. %%%=================================================================== %%% gen_server callbacks %%%=================================================================== init([Host|_]) -> process_flag(trap_exit, true), catch ets:new(?MODULE, [named_table, public, {heir, erlang:group_leader(), none}]), ejabberd_hooks:add(component_connected, ?MODULE, component_connected, 50), ejabberd_hooks:add(component_disconnected, ?MODULE, component_disconnected, 50), ejabberd_hooks:add(local_send_to_resource_hook, Host, ?MODULE, process_message, 50), ejabberd_hooks:add(roster_remote_access, Host, ?MODULE, roster_access, 50), ejabberd_hooks:add(user_send_packet, Host, ?MODULE, process_presence_out, 50), ejabberd_hooks:add(user_receive_packet, Host, ?MODULE, process_presence_in, 50), ejabberd_hooks:add(component_send_packet, ?MODULE, component_send_packet, 50), {ok, #state{server_host = Host}}. handle_call(Request, From, State) -> ?WARNING_MSG("Unexpected call from ~p: ~p", [From, Request]), {noreply, State}. handle_cast({component_connected, Host}, State) -> ServerHost = State#state.server_host, From = jid:make(ServerHost), To = jid:make(Host), RosterPerm = get_roster_permission(ServerHost, Host), IqNamespaces = get_iq_namespaces(ServerHost, Host), PresencePerm = get_presence_permission(ServerHost, Host), MessagePerm = get_message_permission(ServerHost, Host), if RosterPerm /= none; IqNamespaces /= []; PresencePerm /= none; MessagePerm /= none -> Priv = #privilege{perms = [#privilege_perm{access = message, type = MessagePerm}, #privilege_perm{access = roster, type = RosterPerm}, #privilege_perm{access = iq, namespaces = IqNamespaces}, #privilege_perm{access = presence, type = PresencePerm}]}, ?INFO_MSG("Granting permissions to external " "component '~ts': roster = ~ts, presence = ~ts, " "message = ~ts,~n iq = ~p", [Host, RosterPerm, PresencePerm, MessagePerm, IqNamespaces]), Msg = #message{from = From, to = To, sub_els = [Priv]}, ejabberd_router:route(Msg), Permissions = maps:put(Host, [{roster, RosterPerm}, {iq, IqNamespaces}, {presence, PresencePerm}, {message, MessagePerm}], get_permissions(ServerHost)), ets:insert(?MODULE, {ServerHost, Permissions}), {noreply, State}; true -> ?INFO_MSG("Granting no permissions to external component '~ts'", [Host]), {noreply, State} end; handle_cast({component_disconnected, Host}, State) -> ServerHost = State#state.server_host, Permissions = maps:remove(Host, get_permissions(ServerHost)), case maps:size(Permissions) of 0 -> ets:delete(?MODULE, ServerHost); _ -> ets:insert(?MODULE, {ServerHost, Permissions}) end, {noreply, State}; handle_cast(Msg, State) -> ?WARNING_MSG("Unexpected cast: ~p", [Msg]), {noreply, State}. handle_info(Info, State) -> ?WARNING_MSG("Unexpected info: ~p", [Info]), {noreply, State}. terminate(_Reason, State) -> Host = State#state.server_host, case gen_mod:is_loaded_elsewhere(Host, ?MODULE) of false -> ejabberd_hooks:delete(component_send_packet, ?MODULE, component_send_packet, 50), ejabberd_hooks:delete(component_connected, ?MODULE, component_connected, 50), ejabberd_hooks:delete(component_disconnected, ?MODULE, component_disconnected, 50); true -> ok end, ejabberd_hooks:delete(local_send_to_resource_hook, Host, ?MODULE, process_message, 50), ejabberd_hooks:delete(roster_remote_access, Host, ?MODULE, roster_access, 50), ejabberd_hooks:delete(user_send_packet, Host, ?MODULE, process_presence_out, 50), ejabberd_hooks:delete(user_receive_packet, Host, ?MODULE, process_presence_in, 50), ets:delete(?MODULE, Host). code_change(_OldVsn, State, _Extra) -> {ok, State}. %%%=================================================================== %%% Internal functions %%%=================================================================== -spec get_permissions(binary()) -> permissions(). get_permissions(ServerHost) -> case ets:lookup(?MODULE, ServerHost) of [] -> #{}; [{_, Permissions}] -> Permissions end. %% %% Message %% -spec forward_message(message()) -> ok. forward_message(#message{to = To} = Msg) -> ServerHost = To#jid.lserver, Lang = xmpp:get_lang(Msg), CodecOpts = ejabberd_config:codec_options(), try xmpp:try_subtag(Msg, #privilege{}) of #privilege{forwarded = #forwarded{sub_els = [SubEl]}} -> try xmpp:decode(SubEl, ?NS_CLIENT, CodecOpts) of #message{} = NewMsg -> case NewMsg#message.from of #jid{lresource = <<"">>, lserver = ServerHost} -> FromJID = NewMsg#message.from, State = #{jid => FromJID}, ejabberd_hooks:run_fold(user_send_packet, FromJID#jid.lserver, {NewMsg, State}, []), ejabberd_router:route(NewMsg); _ -> Lang = xmpp:get_lang(Msg), Txt = ?T("Invalid 'from' attribute in forwarded message"), Err = xmpp:err_forbidden(Txt, Lang), ejabberd_router:route_error(Msg, Err) end; _ -> Txt = ?T("Message not found in forwarded payload"), Err = xmpp:err_bad_request(Txt, Lang), ejabberd_router:route_error(Msg, Err) catch _:{xmpp_codec, Why} -> Txt = xmpp:io_format_error(Why), Err = xmpp:err_bad_request(Txt, Lang), ejabberd_router:route_error(Msg, Err) end; _ -> Txt = ?T("No element found"), Err = xmpp:err_bad_request(Txt, Lang), ejabberd_router:route_error(Msg, Err) catch _:{xmpp_codec, Why} -> Txt = xmpp:io_format_error(Why), Err = xmpp:err_bad_request(Txt, Lang), ejabberd_router:route_error(Msg, Err) end. %% %% IQ %% %% @format-begin -spec get_iq_encapsulated_details(iq()) -> {ok, iq_type(), binary(), jid(), iq()} | {error, Why :: atom(), stanza_error()}. get_iq_encapsulated_details(#iq{sub_els = [IqSub]} = Msg) -> Lang = xmpp:get_lang(Msg), try xmpp:try_subtag(Msg, #privileged_iq{}) of #privileged_iq{iq = #iq{type = EncapsulatedType, from = From} = EncIq} -> [IqSubSub] = xmpp:get_els(IqSub), [Element] = xmpp:get_els(IqSubSub), Ns = xmpp:get_ns(Element), {ok, EncapsulatedType, Ns, From, EncIq}; _ -> try xmpp:try_subtag(Msg, #roster_query{}) of #roster_query{} -> {error, roster_query, xmpp:err_bad_request()}; _ -> Txt = ?T("No element found"), Err = xmpp:err_bad_request(Txt, Lang), {error, no_privileged_iq, Err} catch _:{xmpp_codec, Why} -> Txt = xmpp:io_format_error(Why), Err = xmpp:err_bad_request(Txt, Lang), {error, codec_error, Err} end catch _:{xmpp_codec, Why} -> Txt = xmpp:io_format_error(Why), Err = xmpp:err_bad_request(Txt, Lang), {error, codec_error, Err} end. -spec forward_iq(binary(), jid(), binary(), iq()) -> iq(). forward_iq(Host, ToplevelTo, Id, Iq) -> FromJID = ToplevelTo, NewIq0 = Iq#iq{from = FromJID}, xmpp:put_meta(NewIq0, privilege_iq, {Id, Host, FromJID}). %% @format-end %% %% Permissions %% -spec get_roster_permission(binary(), binary()) -> roster_permission() | none. get_roster_permission(ServerHost, Host) -> Perms = mod_privilege_opt:roster(ServerHost), case match_rule(ServerHost, Host, Perms, both) of allow -> both; deny -> Get = match_rule(ServerHost, Host, Perms, get), Set = match_rule(ServerHost, Host, Perms, set), if Get == allow, Set == allow -> both; Get == allow -> get; Set == allow -> set; true -> none end end. -spec get_iq_namespaces(binary(), binary()) -> [privilege_namespace()]. get_iq_namespaces(ServerHost, Host) -> NsPerms = mod_privilege_opt:iq(ServerHost), [#privilege_namespace{ns = Ns, type = get_iq_permission(ServerHost, Host, Perms)} || {Ns, Perms} <- NsPerms]. -spec get_iq_permission(binary(), binary(), [iq_permission()]) -> iq_permission() | none. get_iq_permission(ServerHost, Host, Perms) -> case match_rule(ServerHost, Host, Perms, both) of allow -> both; deny -> Get = match_rule(ServerHost, Host, Perms, get), Set = match_rule(ServerHost, Host, Perms, set), if Get == allow, Set == allow -> both; Get == allow -> get; Set == allow -> set; true -> none end end. -spec get_message_permission(binary(), binary()) -> message_permission() | none. get_message_permission(ServerHost, Host) -> Perms = mod_privilege_opt:message(ServerHost), case match_rule(ServerHost, Host, Perms, outgoing) of allow -> outgoing; deny -> none end. -spec get_presence_permission(binary(), binary()) -> presence_permission() | none. get_presence_permission(ServerHost, Host) -> Perms = mod_privilege_opt:presence(ServerHost), case match_rule(ServerHost, Host, Perms, roster) of allow -> roster; deny -> case match_rule(ServerHost, Host, Perms, managed_entity) of allow -> managed_entity; deny -> none end end. -ifdef(OTP_BELOW_26). -dialyzer({no_contracts, match_rule/4}). -endif. -spec match_rule(binary(), binary(), roster_permissions(), roster_permission()) -> allow | deny; (binary(), binary(), iq_permissions(), iq_permission()) -> allow | deny; (binary(), binary(), presence_permissions(), presence_permission()) -> allow | deny; (binary(), binary(), message_permissions(), message_permission()) -> allow | deny. match_rule(ServerHost, Host, Perms, Type) -> Access = proplists:get_value(Type, Perms, none), acl:match_rule(ServerHost, Access, jid:make(Host)).