File

plugins/mod_blocklist.lua @ 13652:a08065207ef0

net.server_epoll: Call :shutdown() on TLS sockets when supported Comment from Matthew: This fixes a potential issue where the Prosody process gets blocked on sockets waiting for them to close. Unlike non-TLS sockets, closing a TLS socket sends layer 7 data, and this can cause problems for sockets which are in the process of being cleaned up. This depends on LuaSec changes which are not yet upstream. From Martijn's original email: So first my analysis of luasec. in ssl.c the socket is put into blocking mode right before calling SSL_shutdown() inside meth_destroy(). My best guess to why this is is because meth_destroy is linked to the __close and __gc methods, which can't exactly be called multiple times and luasec does want to make sure that a tls session is shutdown as clean as possible. I can't say I disagree with this reasoning and don't want to change this behaviour. My solution to this without changing the current behaviour is to introduce a shutdown() method. I am aware that this overlaps in a conflicting way with tcp's shutdown method, but it stays close to the OpenSSL name. This method calls SSL_shutdown() in the current (non)blocking mode of the underlying socket and returns a boolean whether or not the shutdown is completed (matching SSL_shutdown()'s 0 or 1 return values), and returns the familiar ssl_ioerror() strings on error with a false for completion. This error can then be used to determine if we have wantread/wantwrite to finalize things. Once meth_shutdown() has been called once a shutdown flag will be set, which indicates to meth_destroy() that the SSL_shutdown() has been handled by the application and it shouldn't be needed to set the socket to blocking mode. I've left the SSL_shutdown() call in the LSEC_STATE_CONNECTED to prevent TOCTOU if the application reaches a timeout for the shutdown code, which might allow SSL_shutdown() to clean up anyway at the last possible moment. Another thing I've changed to luasec is the call to socket_setblocking() right before calling close(2) in socket_destroy() in usocket.c. According to the latest POSIX[0]: Note that the requirement for close() on a socket to block for up to the current linger interval is not conditional on the O_NONBLOCK setting. Which I read to mean that removing O_NONBLOCK on the socket before close doesn't impact the behaviour and only causes noise in system call tracers. I didn't touch the windows bits of this, since I don't do windows. For the prosody side of things I've made the TLS shutdown bits resemble interface:onwritable(), and put it under a combined guard of self._tls and self.conn.shutdown. The self._tls bit is there to prevent getting stuck on this condition, and self.conn.shutdown is there to prevent the code being called by instances where the patched luasec isn't deployed. The destroy() method can be called from various places and is read by me as the "we give up" error path. To accommodate for these unexpected entrypoints I've added a single call to self.conn:shutdown() to prevent the socket being put into blocking mode. I have no expectations that there is any other use here. Same as previous, the self.conn.shutdown check is there to make sure it's not called on unpatched luasec deployments and self._tls is there to make sure we don't call shutdown() on tcp sockets. I wouldn't recommend logging of the conn:shutdown() error inside close(), since a lot of clients simply close the connection before SSL_shutdown() is done.
author Martijn van Duren <martijn@openbsd.org>
date Thu, 06 Feb 2025 15:04:38 +0000
parent 13489:ae65f199f408
line wrap: on
line source

-- Prosody IM
-- Copyright (C) 2009-2010 Matthew Wild
-- Copyright (C) 2009-2010 Waqas Hussain
-- Copyright (C) 2014-2015 Kim Alvefur
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--
-- This module implements XEP-0191: Blocking Command
--

local user_exists = require"prosody.core.usermanager".user_exists;
local rostermanager = require"prosody.core.rostermanager";
local is_contact_subscribed = rostermanager.is_contact_subscribed;
local is_contact_pending_in = rostermanager.is_contact_pending_in;
local load_roster = rostermanager.load_roster;
local save_roster = rostermanager.save_roster;
local st = require"prosody.util.stanza";
local st_error_reply = st.error_reply;
local jid_prep = require"prosody.util.jid".prep;
local jid_split = require"prosody.util.jid".split;

local storage = module:open_store();
local sessions = prosody.hosts[module.host].sessions;
local full_sessions = prosody.full_sessions;

-- Cache of blocklists, keeps a fixed number of items.
--
-- The size of this affects how often we will need to load a blocklist from
-- disk, which we want to avoid during routing. On the other hand, we don't
-- want to use too much memory either, so this can be tuned by advanced
-- users. TODO use science to figure out a better default, 64 is just a guess.
local cache_size = module:get_option_integer("blocklist_cache_size", 256, 1);
local blocklist_cache = require"prosody.util.cache".new(cache_size);

local null_blocklist = {};

module:add_feature("urn:xmpp:blocking");

local function set_blocklist(username, blocklist)
	local ok, err = storage:set(username, blocklist);
	if not ok then
		return ok, err;
	end
	-- Successful save, update the cache
	blocklist_cache:set(username, blocklist);
	return true;
end

-- Migrates from the old mod_privacy storage
-- TODO mod_privacy was removed in 0.10.0, this should be phased out
local function migrate_privacy_list(username)
	local legacy_data = module:open_store("privacy"):get(username);
	if not legacy_data or not legacy_data.lists or not legacy_data.default then return; end
	local default_list = legacy_data.lists[legacy_data.default];
	if not default_list or not default_list.items then return; end

	local migrated_data = { [false] = { created = os.time(); migrated = "privacy" }};

	module:log("info", "Migrating blocklist from mod_privacy storage for user '%s'", username);
	for _, item in ipairs(default_list.items) do
		if item.type == "jid" and item.action == "deny" then
			local jid = jid_prep(item.value);
			if not jid then
				module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, item.value);
			else
				migrated_data[jid] = true;
			end
		end
	end
	set_blocklist(username, migrated_data);
	return migrated_data;
end

if not module:get_option_boolean("migrate_legacy_blocking", true) then
	migrate_privacy_list = function (username)
		module:log("debug", "Migrating from mod_privacy disabled, user '%s' will start with a fresh blocklist", username);
		return nil;
	end
end

local function get_blocklist(username)
	local blocklist = blocklist_cache:get(username);
	if not blocklist then
		if not user_exists(username, module.host) then
			return null_blocklist;
		end
		blocklist = storage:get(username);
		if not blocklist then
			blocklist = migrate_privacy_list(username);
		end
		if not blocklist then
			blocklist = { [false] = { created = os.time(); }; };
		end
		blocklist_cache:set(username, blocklist);
	end
	return blocklist;
end

module:hook("iq-get/self/urn:xmpp:blocking:blocklist", function (event)
	local origin, stanza = event.origin, event.stanza;
	local username = origin.username;
	local reply = st.reply(stanza):tag("blocklist", { xmlns = "urn:xmpp:blocking" });
	local blocklist = get_blocklist(username);
	for jid in pairs(blocklist) do
		if jid then
			reply:tag("item", { jid = jid }):up();
		end
	end
	origin.interested_blocklist = true; -- Gets notified about changes
	origin.send(reply);
	return true;
end, -1);

-- Add or remove some jid(s) from the blocklist
-- We want this to be atomic and not do a partial update
local function edit_blocklist(event)
	local now = os.time();
	local origin, stanza = event.origin, event.stanza;
	local username = origin.username;
	local action = stanza.tags[1]; -- "block" or "unblock"
	local is_blocking = action.name == "block" and now or nil; -- nil if unblocking
	local new = {}; -- JIDs to block depending or unblock on action


	-- XEP-0191 sayeth:
	-- > When the user blocks communications with the contact, the user's
	-- > server MUST send unavailable presence information to the contact (but
	-- > only if the contact is allowed to receive presence notifications [...]
	-- So contacts we need to do that for are added to the set below.
	local send_unavailable = is_blocking and {};
	local send_available = not is_blocking and {};

	-- Because blocking someone currently also blocks the ability to reject
	-- subscription requests, we'll preemptively reject such
	local remove_pending = is_blocking and {};

	for item in action:childtags("item") do
		local jid = jid_prep(item.attr.jid);
		if not jid then
			origin.send(st_error_reply(stanza, "modify", "jid-malformed"));
			return true;
		end
		item.attr.jid = jid; -- echo back prepped
		new[jid] = true;
		if is_blocking then
			if is_contact_subscribed(username, module.host, jid) then
				send_unavailable[jid] = true;
			elseif is_contact_pending_in(username, module.host, jid) then
				remove_pending[jid] = true;
			end
		elseif is_contact_subscribed(username, module.host, jid) then
			send_available[jid] = true;
		end
	end

	if is_blocking and not next(new) then
		-- <block/> element does not contain at least one <item/> child element
		origin.send(st_error_reply(stanza, "modify", "bad-request"));
		return true;
	end

	local blocklist = get_blocklist(username);

	local new_blocklist = {
		-- We set the [false] key to something as a signal not to migrate privacy lists
		[false] = blocklist[false] or { created = now; };
	};
	if type(blocklist[false]) == "table" then
		new_blocklist[false].modified = now;
	end

	if is_blocking or next(new) then
		for jid, t in pairs(blocklist) do
			if jid then new_blocklist[jid] = t; end
		end
		for jid in pairs(new) do
			new_blocklist[jid] = is_blocking;
		end
		-- else empty the blocklist
	end

	local ok, err = set_blocklist(username, new_blocklist);
	if ok then
		origin.send(st.reply(stanza));
	else
		origin.send(st_error_reply(stanza, "wait", "internal-server-error", err));
		return true;
	end

	if is_blocking then
		for jid in pairs(send_unavailable) do
			-- Check that this JID isn't already blocked, i.e. this is not a change
			if not blocklist[jid] then
				for _, session in pairs(sessions[username].sessions) do
					if session.presence then
						module:send(st.presence({ type = "unavailable", to = jid, from = session.full_jid }));
					end
				end
			end
		end

		if next(remove_pending) then
			local roster = load_roster(username, module.host);
			for jid in pairs(remove_pending) do
				roster[false].pending[jid] = nil;
			end
			save_roster(username, module.host, roster);
			-- Not much we can do about save failing here
		end
	else
		local user_bare = username .. "@" .. module.host;
		for jid in pairs(send_available) do
			module:send(st.presence({ type = "probe", to = user_bare, from = jid }));
		end
	end

	local blocklist_push = st.iq({ type = "set", id = "blocklist-push" })
		:add_child(action); -- I am lazy

	for _, session in pairs(sessions[username].sessions) do
		if session.interested_blocklist then
			blocklist_push.attr.to = session.full_jid;
			session.send(blocklist_push);
		end
	end

	return true;
end

module:hook("iq-set/self/urn:xmpp:blocking:block", edit_blocklist, -1);
module:hook("iq-set/self/urn:xmpp:blocking:unblock", edit_blocklist, -1);

-- Cache invalidation, solved!
module:hook_global("user-deleted", function (event)
	if event.host == module.host then
		blocklist_cache:set(event.username, nil);
	end
end);

-- Buggy clients
module:hook("iq-error/self/blocklist-push", function (event)
	local origin, stanza = event.origin, event.stanza;
	local _, condition, text = stanza:get_error();
	local log = (origin.log or module._log);
	log("warn", "Client returned an error in response to notification from mod_%s: %s%s%s",
		module.name, condition, text and ": " or "", text or "");
	return true;
end);

local function is_blocked(user, jid)
	local blocklist = get_blocklist(user);
	if blocklist[jid] then return true; end
	local node, host = jid_split(jid);
	return blocklist[host] or node and blocklist[node..'@'..host];
end

-- Event handlers for bouncing or dropping stanzas
local function drop_stanza(event)
	local stanza = event.stanza;
	local attr = stanza.attr;
	local to, from = attr.to, attr.from;
	to = to and jid_split(to);
	if to and from then
		if is_blocked(to, from) then
			return true;
		end

		-- Check mediated MUC inviter
		if stanza.name == "message" then
			local invite = stanza:find("{http://jabber.org/protocol/muc#user}x/invite");
			if invite then
				from = jid_prep(invite.attr.from);
				if is_blocked(to, from) then
					return true;
				end
			end
		end
	end
end

local function bounce_stanza(event)
	local origin, stanza = event.origin, event.stanza;
	if drop_stanza(event) then
		origin.send(st_error_reply(stanza, "cancel", "service-unavailable"));
		return true;
	end
end

local function bounce_iq(event)
	local type = event.stanza.attr.type;
	if type == "set" or type == "get" then
		return bounce_stanza(event);
	end
	return drop_stanza(event); -- result or error
end

local function bounce_message(event)
	local stanza = event.stanza;
	local type = stanza.attr.type;
	if type == "chat" or not type or type == "normal" then
		if full_sessions[stanza.attr.to] then
			-- See #690
			return drop_stanza(event);
		end
		return bounce_stanza(event);
	end
	return drop_stanza(event); -- drop headlines, groupchats etc
end

local function drop_outgoing(event)
	local origin, stanza = event.origin, event.stanza;
	local username = origin.username or jid_split(stanza.attr.from);
	if not username then return end
	local to = stanza.attr.to;
	if to then return is_blocked(username, to); end
	-- nil 'to' means a self event, don't bock those
end

local function bounce_outgoing(event)
	local origin, stanza = event.origin, event.stanza;
	local type = stanza.attr.type;
	if type == "error" or stanza.name == "iq" and type == "result" then
		return drop_outgoing(event);
	end
	if drop_outgoing(event) then
		origin.send(st_error_reply(stanza, "cancel", "not-acceptable", "You have blocked this JID")
			:tag("blocked", { xmlns = "urn:xmpp:blocking:errors" }));
		return true;
	end
end

-- Hook all the events!
local prio_in, prio_out = 100, 100;
module:hook("presence/bare", drop_stanza, prio_in);
module:hook("presence/full", drop_stanza, prio_in);

if module:get_option_boolean("bounce_blocked_messages", false) then
	module:hook("message/bare", bounce_message, prio_in);
	module:hook("message/full", bounce_message, prio_in);
else
	module:hook("message/bare", drop_stanza, prio_in);
	module:hook("message/full", drop_stanza, prio_in);
end

module:hook("iq/bare", bounce_iq, prio_in);
module:hook("iq/full", bounce_iq, prio_in);

module:hook("pre-message/bare", bounce_outgoing, prio_out);
module:hook("pre-message/full", bounce_outgoing, prio_out);
module:hook("pre-message/host", bounce_outgoing, prio_out);

module:hook("pre-presence/bare", bounce_outgoing, -1);
module:hook("pre-presence/host", bounce_outgoing, -1);
module:hook("pre-presence/full", bounce_outgoing, prio_out);

module:hook("pre-iq/bare", bounce_outgoing, prio_out);
module:hook("pre-iq/full", bounce_outgoing, prio_out);
module:hook("pre-iq/host", bounce_outgoing, prio_out);