Changeset

5009:459a4001c1d9

mod_restrict_xmpp: XMPP-layer access control using Prosody's permissions API
author Matthew Wild <mwild1@gmail.com>
date Mon, 22 Aug 2022 20:03:23 +0100
parents 5008:bd63feda3704
children 5010:a1f49586d28a
files mod_restrict_xmpp/README.markdown mod_restrict_xmpp/mod_restrict_xmpp.lua
diffstat 2 files changed, 171 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_restrict_xmpp/README.markdown	Mon Aug 22 20:03:23 2022 +0100
@@ -0,0 +1,51 @@
+---
+labels:
+- Stage-Alpha
+summary: XMPP-layer access control for Prosody
+---
+
+Introduction
+============
+
+This module enforces access policies using Prosody's new [roles and
+permissions framework](https://prosody.im/doc/developers/permissions). It can
+be used to grant restricted access to an XMPP account or services.
+
+This module is still in its early stages, and prone to change. Feedback from
+testers is welcome. At this early stage, it should not be solely relied upon
+for account security purposes.
+
+Configuration
+=============
+
+There is no configuration, apart from Prosody's normal roles and permissions
+configuration.
+
+Permissions
+===========
+
+`xmpp:federate`
+: Communicate with other users and services on other hosts on the XMPP network
+`xmpp:account:messages:read`
+: Read incoming messages
+`xmpp:account:messages:write`
+: Send outgoing messages
+`xmpp:account:presence:write`
+: Update presence for the account
+`xmpp:account:contacts:read`/`xmpp:account:contacts:write`
+: Controls access to the contact list (roster)
+`xmpp:account:bookmarks:read`/`xmpp:account:bookmarks:write`
+: Controls access to the bookmarks (group chats list)
+`xmpp:account:profile:read`/`xmpp:account:profile:write`
+: Controls access to the user's profile (e.g. vCard/avatar)
+`xmpp:account:omemo:read`/`xmpp:account:omemo:write`
+: Controls access to the user's OMEMO data
+`xmpp:account:blocklist:read`/`xmpp:account:blocklist:write`
+: Controls access to the user's block list
+`xmpp:account:disco:read`
+: Controls access to the user's service discovery information
+
+Compatibility
+=============
+
+Requires Prosody trunk 72f431b4dc2c (build 1444) or later.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_restrict_xmpp/mod_restrict_xmpp.lua	Mon Aug 22 20:03:23 2022 +0100
@@ -0,0 +1,120 @@
+local array = require "util.array";
+local it = require "util.iterators";
+local set = require "util.set";
+local st = require "util.stanza";
+
+module:default_permission("prosody:user", "xmpp:federate");
+module:hook("route/remote", function (event)
+	if not module:may("xmpp:federate", event) then
+		if event.stanza.attr.type ~= "result" and event.stanza.attr.type ~= "error" then
+			module:log("warn", "Access denied: xmpp:federate for %s -> %s", event.stanza.attr.from, event.stanza.attr.to);
+			local reply = st.error_reply(event.stanza, "auth", "forbidden");
+			event.origin.send(reply);
+		end
+		return true;
+	end
+end);
+
+local iq_namespaces = {
+	["jabber:iq:roster"] = "contacts";
+	["jabber:iq:private"] = "storage";
+
+	["vcard-temp"] = "profile";
+	["urn:xmpp:mam:0"] = "history";
+	["urn:xmpp:mam:1"] = "history";
+	["urn:xmpp:mam:2"] = "history";
+
+	["urn:xmpp:carbons:0"] = "carbons";
+	["urn:xmpp:carbons:1"] = "carbons";
+	["urn:xmpp:carbons:2"] = "carbons";
+
+	["urn:xmpp:blocking"] = "blocklist";
+
+	["http://jabber.org/protocol/pubsub"] = "pep";
+	["http://jabber.org/protocol/disco#info"] = "disco";
+};
+
+local legacy_storage_nodes = {
+	["storage:bookmarks"] = "bookmarks";
+	["storage:rosternotes"] = "contacts";
+	["roster:delimiter"] = "contacts";
+	["storage:metacontacts"] = "contacts";
+};
+
+local pep_nodes = {
+	["storage:bookmarks"] = "bookmarks";
+	["urn:xmpp:bookmarks:1"] = "bookmarks";
+
+	["urn:xmpp:avatar:data"] = "profile";
+	["urn:xmpp:avatar:metadata"] = "profile";
+	["http://jabber.org/protocol/nick"] = "profile";
+
+	["eu.siacs.conversations.axolotl.devicelist"] = "omemo";
+	["urn:xmpp:omemo:1:devices"] = "omemo";
+	["urn:xmpp:omemo:1:bundles"] = "omemo";
+	["urn:xmpp:omemo:2:devices"] = "omemo";
+	["urn:xmpp:omemo:2:bundles"] = "omemo";
+};
+
+module:hook("pre-iq/bare", function (event)
+	if not event.to_self then return; end
+	local origin, stanza = event.origin, event.stanza;
+
+	local typ = stanza.attr.type;
+	if typ ~= "set" and typ ~= "get" then return; end
+	local action = typ == "get" and "read" or "write";
+
+	local payload = stanza.tags[1];
+	local ns = payload and payload.attr.xmlns;
+	local proto = iq_namespaces[ns];
+	if proto == "pep" then
+		local pubsub = payload:get_child("pubsub", "http://jabber.org/protocol/pubsub");
+		local node = pubsub and #pubsub.tags == 1 and pubsub.tags[1].attr.node or nil;
+		proto = pep_nodes[node] or "pep";
+		if proto == "pep" and node and node:match("^eu%.siacs%.conversations%.axolotl%.bundles%.%d+$") then
+			proto = "omemo"; -- COMPAT w/ original OMEMO
+		end
+	elseif proto == "storage" then
+		local data = payload.tags[1];
+		proto = data and legacy_storage_nodes[data.attr.xmlns] or "legacy-storage";
+	elseif proto == "carbons" then
+		-- This allows access to live messages
+		proto, action = "messages", "read";
+	end
+	local permission_name = "xmpp:account:"..(proto and (proto..":") or "")..action;
+	if not module:may(permission_name, event) then
+		module:log("warn", "Access denied: %s ({%s}%s) for %s", permission_name, ns, payload.name, origin.full_jid or origin.id);
+		origin.send(st.error_reply(stanza, "auth", "forbidden", "You do not have permission to make this request ("..permission_name..")"));
+		return true;
+	end
+end);
+
+--module:default_permission("prosody:restricted", "xmpp:account:read");
+--module:default_permission("prosody:restricted", "xmpp:account:write");
+module:default_permission("prosody:restricted", "xmpp:account:messages:read");
+module:default_permission("prosody:restricted", "xmpp:account:messages:write");
+for _, property_list in ipairs({ iq_namespaces, legacy_storage_nodes, pep_nodes }) do
+	for account_property in set.new(array.collect(it.values(property_list))) do
+		module:default_permission("prosody:restricted", "xmpp:account:"..account_property..":read");
+		module:default_permission("prosody:restricted", "xmpp:account:"..account_property..":write");
+	end
+end
+
+module:default_permission("prosody:restricted", "xmpp:account:presence:write");
+module:hook("pre-presence/bare", function (event)
+	if not event.to_self then return; end
+	local stanza = event.stanza;
+	if not module:may("xmpp:account:presence:write", event) then
+		module:log("warn", "Access denied: xmpp:account:presence:write for %s", event.origin.full_jid or event.origin.id);
+		event.origin.send(st.error_reply(stanza, "auth", "forbidden", "You do not have permission to send account presence"));
+		return true;
+	end
+	local priority = stanza:get_child_text("priority");
+	if priority ~= "-1" then
+		if not module:may("xmpp:account:messages:read", event) then
+			module:log("warn", "Access denied: xmpp:account:messages:read for %s", event.origin.full_jid or event.origin.id);
+			event.origin.send(st.error_reply(stanza, "auth", "forbidden", "You do not have permission to receive messages (use presence priority -1)"));
+			return true;
+		end
+	end
+end);