view mod_privilege/mod_privilege.lua @ 5401:c8d04ac200fc

mod_http_oauth2: Reject loopback URIs as client_uri This really should be a proper website with info, https://localhost is not good enough. Ideally we'd validate that it's got proper DNS and is actually reachable, but triggering HTTP or even DNS lookups seems like it would carry abuse potential that would best to avoid.
author Kim Alvefur <zash@zash.se>
date Tue, 02 May 2023 16:20:55 +0200
parents cce12a660b98
children a88c43de648c
line wrap: on
line source

-- XEP-0356 (Privileged Entity)
-- Copyright (C) 2015-2022 Jérôme Poisson
--
-- This module is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--
-- Some parts come from mod_remote_roster (module by Waqas Hussain and Kim Alvefur, see https://code.google.com/p/prosody-modules/)

-- TODO: manage external <presence/> (for "roster" presence permission) when the account with the roster is offline

local jid = require("util.jid")
local set = require("util.set")
local st = require("util.stanza")
local roster_manager = require("core.rostermanager")
local usermanager_user_exists = require "core.usermanager".user_exists
local hosts = prosody.hosts
local full_sessions = prosody.full_sessions

local priv_session = module:shared("/*/privilege/session")

if priv_session.connected_cb == nil then
    -- set used to have connected event listeners
    -- which allows a host to react on events from
    -- other hosts
    priv_session.connected_cb = set.new()
end
local connected_cb = priv_session.connected_cb

-- the folowing sets are used to forward presence stanza
-- the folowing sets are used to forward presence stanza
local presence_man_ent = set.new()
local presence_roster = set.new()

local _ALLOWED_ROSTER = set.new({'none', 'get', 'set', 'both'})
local _ROSTER_GET_PERM = set.new({'get', 'both'})
local _ROSTER_SET_PERM = set.new({'set', 'both'})
local _ALLOWED_MESSAGE = set.new({'none', 'outgoing'})
local _ALLOWED_PRESENCE = set.new({'none', 'managed_entity', 'roster'})
local _PRESENCE_MANAGED = set.new({'managed_entity', 'roster'})
local _TO_CHECK = {roster=_ALLOWED_ROSTER, message=_ALLOWED_MESSAGE, presence=_ALLOWED_PRESENCE}
local _PRIV_ENT_NS = 'urn:xmpp:privilege:2'
local _FORWARDED_NS = 'urn:xmpp:forward:0'
local _MODULE_HOST = module:get_host()


module:log("debug", "Loading privileged entity module ")


--> Permissions management <--

local config_priv = module:get_option("privileged_entities", {})

local function get_session_privileges(session, host)
    if not session.privileges then return nil end
    return session.privileges[host]
end


local function advertise_perm(session, to_jid, perms)
    -- send <message/> stanza to advertise permissions
    -- as expained in § 4.2
    local message = st.message({from=module.host, to=to_jid})
                      :tag("privilege", {xmlns=_PRIV_ENT_NS})

    for _, perm in pairs({'roster', 'message', 'presence'}) do
        if perms[perm] then
            message:tag("perm", {access=perm, type=perms[perm]}):up()
        end
    end
    local iq_perm = perms["iq"]
    if iq_perm ~= nil then
        message:tag("perm", {access="iq"})
        for namespace, ns_perm in pairs(iq_perm) do
                local perm_type
                if ns_perm.set and ns_perm.get then
                    perm_type = "both"
                elseif ns_perm.set then
                    perm_type = "set"
                elseif ns_perm.get then
                    perm_type = "get"
                else
                    perm_type = nil
                end
                message:tag("namespace", {ns=namespace, type=perm_type})
        end
    end
    session.send(message)
end

local function set_presence_perm_set(to_jid, perms)
    -- fill the presence sets according to perms
    if _PRESENCE_MANAGED:contains(perms.presence) then
        presence_man_ent:add(to_jid)
    end
    if perms.presence == 'roster' then
        presence_roster:add(to_jid)
    end
end

local function advertise_presences(session, to_jid, perms)
    -- send presence status for already connected entities
    -- as explained in § 7.1
    -- people in roster are probed only for active sessions
    -- TODO: manage roster load for inactive sessions
    if not perms.presence then return; end
    local to_probe = {}
    for _, user_session in pairs(full_sessions) do
        if user_session.presence and _PRESENCE_MANAGED:contains(perms.presence) then
            local presence = st.clone(user_session.presence)
            presence.attr.to = to_jid
            module:log("debug", "sending current presence for "..tostring(user_session.full_jid))
            session.send(presence)
        end
        if perms.presence == "roster" then
            -- we reset the cache to avoid to miss a presence that just changed
            priv_session.last_presence = nil

            if user_session.roster then
                local bare_jid = jid.bare(user_session.full_jid)
                for entity, item in pairs(user_session.roster) do
                    if entity~=false and entity~="pending" and (item.subscription=="both" or item.subscription=="to") then
                        local _, host = jid.split(entity)
                        if not hosts[host] then -- we don't probe jid from hosts we manage
                            -- using a table with entity as key avoid probing several time the same one
                            to_probe[entity] = bare_jid
                        end
                    end
                end
            end
        end
    end

    -- now we probe peoples for "roster" presence permission
    for probe_to, probe_from in pairs(to_probe) do
        module:log("debug", "probing presence for %s (on behalf of %s)", tostring(probe_to), tostring(probe_from))
        local probe = st.presence({from=probe_from, to=probe_to, type="probe"})
        prosody.core_route_stanza(nil, probe)
    end
end


local function on_auth(event)
    -- Check if entity is privileged according to configuration,
    -- and set session.privileges accordingly

    local session = event.session
    local bare_jid = jid.join(session.username, session.host)
    if not session.privileges then
        session.privileges = {}
    end

    local conf_ent_priv = config_priv[bare_jid]
    local ent_priv = {}
    if conf_ent_priv ~= nil then
        module:log("debug", "Entity is privileged")
        for perm_type, allowed_values in pairs(_TO_CHECK) do
            local value = conf_ent_priv[perm_type]
            if value ~= nil then
                if not allowed_values:contains(value) then
                    module:log('warn', 'Invalid value for '..perm_type..' privilege: ['..value..']')
                    module:log('warn', 'Setting '..perm_type..' privilege to none')
                    ent_priv[perm_type] = nil
                elseif value == 'none' then
                    ent_priv[perm_type] = nil
                else
                    ent_priv[perm_type] = value
                end
            else
                ent_priv[perm_type] = nil
            end
        end
        -- extra checks for presence permission
        if ent_priv.presence == 'roster' and not _ROSTER_GET_PERM:contains(ent_priv.roster) then
            module:log("warn", "Can't allow roster presence privilege without roster \"get\" privilege")
            module:log("warn", "Setting presence permission to none")
            ent_priv.presence = nil
        end
        -- iq permission
        local iq_perm_config = conf_ent_priv["iq"]
        if iq_perm_config ~= nil then
            local iq_perm = {}
            ent_priv["iq"] = iq_perm
            for ns, ns_perm_config in pairs(iq_perm_config) do
                iq_perm[ns] = {
                    ["get"] = ns_perm_config == "get" or ns_perm_config == "both",
                    ["set"] = ns_perm_config == "set" or ns_perm_config == "both"
                }
            end
        else
            ent_priv["iq"] = nil
        end

        if session.type == "component" then
            -- we send the message stanza only for component
            -- it will be sent at first <presence/> for other entities
            advertise_perm(session, bare_jid, ent_priv)
            set_presence_perm_set(bare_jid, ent_priv)
            advertise_presences(session, bare_jid, ent_priv)
        end
    end

    session.privileges[_MODULE_HOST] = ent_priv
end

local function on_presence(event)
    -- Permission are already checked at this point,
    -- we only advertise them to the entity
    local session = event.origin
    local session_privileges = get_session_privileges(session, _MODULE_HOST)
    if session_privileges then
        advertise_perm(session, session.full_jid, session_privileges)
        set_presence_perm_set(session.full_jid, session_privileges)
        advertise_presences(session, session.full_jid, session_privileges)
    end
end

local function on_component_auth(event)
    -- react to component-authenticated event from this host
    -- and call the on_auth methods from all other hosts
    -- needed for the component to get delegations advertising
    for callback in connected_cb:items() do
        callback(event)
    end
end

if module:get_host_type() ~= "component" then
    connected_cb:add(on_auth)
end
module:hook('authentication-success', on_auth)
module:hook('component-authenticated', on_component_auth)
module:hook('presence/initial', on_presence)


--> roster permission <--

-- get
module:hook("iq-get/bare/jabber:iq:roster:query", function(event)
    local session, stanza = event.origin, event.stanza
    if not stanza.attr.to then
        -- we don't want stanzas addressed to /self
        return
    end
    local node, host = jid.split(stanza.attr.to)
    local session_privileges = get_session_privileges(session, host)

    if session_privileges and _ROSTER_GET_PERM:contains(session_privileges.roster) then
        module:log("debug", "Roster get from allowed privileged entity received")
        -- following code is adapted from mod_remote_roster
        local roster = roster_manager.load_roster(node, host)

        local reply = st.reply(stanza):query("jabber:iq:roster")
        for entity_jid, item in pairs(roster) do
            if entity_jid and entity_jid ~= "pending" then
                reply:tag("item", {
                    jid = entity_jid,
                    subscription = item.subscription,
                    ask = item.ask,
                    name = item.name,
                })
                for group in pairs(item.groups) do
                    reply:tag("group"):text(group):up()
                end
                reply:up(); -- move out from item
            end
        end
        -- end of code adapted from mod_remote_roster
        session.send(reply)
    else
        module:log("warn", "Entity "..tostring(session.full_jid).." try to get roster without permission")
        session.send(st.error_reply(stanza, 'auth', 'forbidden'))
    end

    return true
end)

-- set
module:hook("iq-set/bare/jabber:iq:roster:query", function(event)
    local session, stanza = event.origin, event.stanza
    if not stanza.attr.to then
        -- we don't want stanzas addressed to /self
        return
    end
    local from_node, from_host = jid.split(stanza.attr.to)
    local session_privileges = get_session_privileges(session, from_host)

    if session_privileges and _ROSTER_SET_PERM:contains(session_privileges.roster) then
        module:log("debug", "Roster set from allowed privileged entity received")
        -- following code is adapted from mod_remote_roster
        if not(usermanager_user_exists(from_node, from_host)) then return; end
        local roster = roster_manager.load_roster(from_node, from_host)
        if not(roster) then return; end

        local query = stanza.tags[1]
        for _, item in ipairs(query.tags) do
            if item.name == "item"
                and item.attr.xmlns == "jabber:iq:roster" and item.attr.jid
                    -- Protection against overwriting roster.pending, until we move it
                and item.attr.jid ~= "pending" then

                local item_jid = jid.prep(item.attr.jid)
                local _, host, resource = jid.split(item_jid)
                if not resource then
                    if item_jid ~= stanza.attr.to then -- not self-item_jid
                        if item.attr.subscription == "remove" then
                            local r_item = roster[item_jid]
                            if r_item then
                                roster[item_jid] = nil
                                if roster_manager.save_roster(from_node, from_host, roster) then
                                    session.send(st.reply(stanza))
                                    roster_manager.roster_push(from_node, from_host, item_jid)
                                else
                                    roster[item_jid] = item
                                    session.send(st.error_reply(stanza, "wait", "internal-server-error", "Unable to save roster"))
                                end
                            else
                                session.send(st.error_reply(stanza, "modify", "item-not-found"))
                            end
                        else
                            local subscription = item.attr.subscription
                            if subscription ~= "both" and subscription ~= "to" and subscription ~= "from" and subscription ~= "none" then -- TODO error on invalid
                                subscription = roster[item_jid] and roster[item_jid].subscription or "none"
                            end
                            local r_item = {name = item.attr.name, groups = {}}
                            if r_item.name == "" then r_item.name = nil; end
                            r_item.subscription = subscription
                            if subscription ~= "both" and subscription ~= "to" then
                                r_item.ask = roster[item_jid] and roster[item_jid].ask
                            end
                            for _, child in ipairs(item) do
                                if child.name == "group" then
                                    local text = table.concat(child)
                                    if text and text ~= "" then
                                        r_item.groups[text] = true
                                    end
                                end
                            end
                            local olditem = roster[item_jid]
                            roster[item_jid] = r_item
                            if roster_manager.save_roster(from_node, from_host, roster) then -- Ok, send success
                                session.send(st.reply(stanza))
                                -- and push change to all resources
                                roster_manager.roster_push(from_node, from_host, item_jid)
                            else -- Adding to roster failed
                                roster[item_jid] = olditem
                                session.send(st.error_reply(stanza, "wait", "internal-server-error", "Unable to save roster"))
                            end
                        end
                    else -- Trying to add self to roster
                        session.send(st.error_reply(stanza, "cancel", "not-allowed"))
                    end
                else -- Invalid JID added to roster
                    module:log("warn", "resource: %s , host: %s", tostring(resource), tostring(host))
                    session.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME what's the correct error?
                end
            else -- Roster set didn't include a single item, or its name wasn't  'item'
                session.send(st.error_reply(stanza, "modify", "bad-request"))
            end
        end -- for loop end
        -- end of code adapted from mod_remote_roster
    else -- The permission is not granted
        module:log("warn", "Entity "..tostring(session.full_jid).." try to set roster without permission")
        session.send(st.error_reply(stanza, 'auth', 'forbidden'))
    end

    return true
end)


--> message permission <--

local function clean_xmlns(node)
    -- Recursively remove "jabber:client" attribute from node.
    -- In Prosody internal routing, xmlns should not be set.
    -- Keeping xmlns would lead to issues like mod_smacks ignoring the outgoing stanza,
    -- so we remove all xmlns attributes with a value of "jabber:client"
    if node.attr.xmlns == 'jabber:client' then
        for childnode in node:childtags() do
            clean_xmlns(childnode)
        end
        node.attr.xmlns = nil
    end
end

module:hook("message/host", function(event)
    local session, stanza = event.origin, event.stanza
    local privilege_elt = stanza:get_child('privilege', _PRIV_ENT_NS)
    if privilege_elt==nil then return; end
    local _, to_host = jid.split(stanza.attr.to)
    local session_privileges = get_session_privileges(session, to_host)

    if session_privileges and session_privileges.message=="outgoing" then
        if #privilege_elt.tags==1 and privilege_elt.tags[1].name == "forwarded"
            and privilege_elt.tags[1].attr.xmlns==_FORWARDED_NS then
            local message_elt = privilege_elt.tags[1]:get_child('message', 'jabber:client')
            if message_elt ~= nil then
                local username, from_host, from_resource = jid.split(message_elt.attr.from)
                if from_resource == nil and hosts[from_host] then -- we only accept bare jids from one of the server hosts
                    clean_xmlns(message_elt);  -- needed do to proper routing
                    local session = {
                        username = username;
                        host = from_host;
                        type = "c2s";
                        log = module._log;
                    }
                    -- at this point everything should be alright, we can send the message
                    prosody.core_post_stanza(session, message_elt, true)
                else -- trying to send a message from a forbidden entity
                    module:log("warn", "Entity "..tostring(session.full_jid).." try to send a message from "..tostring(message_elt.attr.from))
                    session.send(st.error_reply(stanza, 'auth', 'forbidden'))
                end
            else -- incorrect message child
                session.send(st.error_reply(stanza, "modify", "bad-request", "invalid forwarded <message/> element"))
            end
        else -- incorrect forwarded child
            session.send(st.error_reply(stanza, "modify", "bad-request", "invalid <forwarded/> element"))
        end
    else -- The permission is not granted
        module:log("warn", "Entity "..tostring(session.full_jid).." try to send message without permission")
        session.send(st.error_reply(stanza, 'auth', 'forbidden'))
    end

    return true
end)


--> presence permission <--

local function same_tags(tag1, tag2)
    -- check if two tags are equivalent

    if tag1.name ~= tag2.name then return false; end

    if #tag1 ~= #tag2 then return false; end

    for name, value in pairs(tag1.attr) do
        if tag2.attr[name] ~= value then return false; end
    end

    for i=1,#tag1 do
        if type(tag1[i]) == "string" then
            if tag1[i] ~= tag2[i] then return false; end
        else
            if not same_tags(tag1[i], tag2[i]) then return false; end
        end
    end

    return true
end

local function same_presences(presence1, presence2)
    -- check that 2 <presence/> stanzas are equivalent (except for "to" attribute)
    -- /!\ if the id change but everything else is equivalent, this method return false
    -- this behaviour may change in the future
    if presence1.attr.from ~= presence2.attr.from or presence1.attr.id ~= presence2.attr.id
        or presence1.attr.type ~= presence2.attr.type then
        return false
    end

    if presence1.attr.id and presence1.attr.id == presence2.attr.id then return true; end

    if #presence1 ~= #presence2 then return false; end

    for i=1,#presence1 do
        if type(presence1[i]) == "string" then
            if presence1[i] ~= presence2[i] then return false; end
        else
            if not same_tags(presence1[i], presence2[i]) then return false; end
        end
    end

    return true
end

local function forward_presence(presence, to_jid)
    local presence_fwd = st.clone(presence)
    presence_fwd.attr.to = to_jid
    module:log("debug", "presence forwarded to "..to_jid..": "..tostring(presence_fwd))
    module:send(presence_fwd)
    -- cache used to avoid to send several times the same stanza
    priv_session.last_presence = presence
end

module:hook("presence/bare", function(event)
    if presence_man_ent:empty() and presence_roster:empty() then return; end

    local stanza = event.stanza
    if stanza.attr.type == nil or stanza.attr.type == "unavailable" then
        if not stanza.attr.to then
            for entity in presence_man_ent:items() do
                if stanza.attr.from ~= entity then forward_presence(stanza, entity); end
            end
        else -- directed presence
            -- we ignore directed presences from our own host, as we already have them
            local _, from_host = jid.split(stanza.attr.from)
            if hosts[from_host] then return; end

            -- we don't send several time the same presence, as recommended in §7 #2
            if priv_session.last_presence and same_presences(priv_session.last_presence, stanza) then
               return
            end

            for entity in presence_roster:items() do
                if stanza.attr.from ~= entity then forward_presence(stanza, entity); end
            end
        end
    end
end, 150)

--> IQ permission <--

module:hook("iq/bare/".._PRIV_ENT_NS..":privileged_iq", function(event)
    local session, stanza = event.origin, event.stanza
    if not stanza.attr.to then
        -- we don't want stanzas addressed to /self
        return
    end
    local from_node, from_host, from_resource = jid.split(stanza.attr.to)

    if from_resource ~= nil or not usermanager_user_exists(from_node, from_host) then
        session.send(
            st.error_reply(
                stanza,
                "auth",
                "forbidden",
                "wrapping <IQ> stanza recipient must be a bare JID of a local user"
            )
        )
        return true
    end

    local session_privileges = get_session_privileges(session, from_host)

    if session_privileges == nil then
        session.send(
            st.error_reply(
                stanza,
                "auth",
                "forbidden",
                "no privilege granted"
            )
        )
        return true
    end

    local iq_privileges = session_privileges["iq"]
    if iq_privileges == nil then
        session.send(
            session.send(st.error_reply(stanza, "auth", "forbidden", "you are not allowed to send privileged <IQ> stanzas"))
        )
        return true
    end

    local privileged_iq = stanza:get_child("privileged_iq", _PRIV_ENT_NS)

    local wrapped_iq = privileged_iq.tags[1]
    if wrapped_iq == nil then
        session.send(
            st.error_reply(stanza, "auth", "forbidden", "missing <IQ> stanza to send")
        )
        return true
    end

    if wrapped_iq.attr.xmlns ~= "jabber:client" then
        session.send(
            st.error_reply(
                stanza,
                "auth",
                "forbidden",
                'wrapped <IQ> must have a xmlns of "jabber:client"'
                )
        )
        return true
    end

    clean_xmlns(wrapped_iq)

    if #wrapped_iq.tags ~= 1 then
        session.send(
            st.error_reply(
                stanza,
                "auth",
                "forbidden",
                'invalid payload in wrapped <IQ>'
                )
        )
        return true
    end

    local payload = wrapped_iq.tags[1]

    local priv_ns = payload.attr.xmlns
    if priv_ns == nil then
        session.send(
            st.error_reply(stanza, "auth", "forbidden", "xmlns not set in privileged <IQ>")
        )
        return true
    end

    local ns_perms = iq_privileges[priv_ns]
    local iq_type = stanza.attr.type
    if ns_perms == nil or iq_type == nil or not ns_perms[iq_type] then
        session.send(
            session.send(st.error_reply(
                stanza,
                "auth",
                "forbidden",
                "you are not allowed to send privileged <IQ> stanzas of this type and namespace")
            )
        )
        return true
    end

    if wrapped_iq.attr.from ~= nil and wrapped_iq.attr.from ~= stanza.attr.to then
        session.send(
            st.error_reply(
                stanza,
                "auth",
                "forbidden",
                'wrapped <IQ> "from" attribute is inconsistent with main <IQ> "to" attribute'
            )
        )
        return true
    end

    wrapped_iq.attr.from = stanza.attr.to


    if wrapped_iq.attr.type ~= iq_type then
        session.send(
            st.error_reply(
                stanza,
                "auth",
                "forbidden",
                'invalid wrapped <IQ>: type mismatch'
            )
        )
        return true
    end

    if wrapped_iq.attr.id == nil then
        session.send(
            st.error_reply(
                stanza,
                "auth",
                "forbidden",
                'invalid wrapped <IQ>: missing "id" attribute'
            )
        )
        return true
    end

    -- at this point, wrapped_iq is considered valid, and privileged entity is allowed to send it
    local username, from_host, _ = jid.split(wrapped_iq.attr.from)
    local newsession = {
        username = username;
        host = from_host;
	    full_jid = stanza.attr.to;
        type = "c2s";
        log = module._log;
    }

    module:send_iq(wrapped_iq,newsession)
        :next(function (response)
            local reply = st.reply(stanza);
            response.stanza.attr.xmlns = 'jabber:client'
            reply:tag("privilege", {xmlns = _PRIV_ENT_NS})
            :tag("forwarded", {xmlns = _FORWARDED_NS})
            :add_child(response.stanza)
            session.send(reply)
        end,
        function(response)
            module:log("error", "Error while sending privileged <IQ>: %s", response);
            session.send(
                st.error_reply(
                    stanza,
                    "cancel",
                    "internal-server-error"
                )
            )
        end)

    return true
end)