File

util/xmppstream.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 12975:d10957394a3c
line wrap: on
line source

-- Prosody IM
-- Copyright (C) 2008-2010 Matthew Wild
-- Copyright (C) 2008-2010 Waqas Hussain
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--

local lxp = require "lxp";
local st = require "prosody.util.stanza";
local stanza_mt = st.stanza_mt;

local error = error;
local tostring = tostring;
local t_insert = table.insert;
local t_concat = table.concat;
local t_remove = table.remove;
local setmetatable = setmetatable;

-- COMPAT: w/LuaExpat 1.1.0
local lxp_supports_doctype = pcall(lxp.new, { StartDoctypeDecl = false });
local lxp_supports_xmldecl = pcall(lxp.new, { XmlDecl = false });
local lxp_supports_bytecount = not not lxp.new({}).getcurrentbytecount;

local default_stanza_size_limit = 1024*1024*1; -- 1MB

local _ENV = nil;
-- luacheck: std none

local new_parser = lxp.new;

local xml_namespace = {
	["http://www.w3.org/XML/1998/namespace\1lang"] = "xml:lang";
	["http://www.w3.org/XML/1998/namespace\1space"] = "xml:space";
	["http://www.w3.org/XML/1998/namespace\1base"] = "xml:base";
	["http://www.w3.org/XML/1998/namespace\1id"] = "xml:id";
};

local xmlns_streams = "http://etherx.jabber.org/streams";

local ns_separator = "\1";
local ns_pattern = "^([^"..ns_separator.."]*)"..ns_separator.."?(.*)$";

local function dummy_cb() end

local function new_sax_handlers(session, stream_callbacks, cb_handleprogress)
	local xml_handlers = {};

	local cb_streamopened = stream_callbacks.streamopened;
	local cb_streamclosed = stream_callbacks.streamclosed;
	local cb_error = stream_callbacks.error or
		function(_, e, stanza)
			error("XML stream error: "..tostring(e)..(stanza and ": "..tostring(stanza) or ""),2);
		end;
	local cb_handlestanza = stream_callbacks.handlestanza;
	cb_handleprogress = cb_handleprogress or dummy_cb;

	local stream_ns = stream_callbacks.stream_ns or xmlns_streams;
	local stream_tag = stream_callbacks.stream_tag or "stream";
	if stream_ns ~= "" then
		stream_tag = stream_ns..ns_separator..stream_tag;
	end
	local stream_error_tag = stream_ns..ns_separator..(stream_callbacks.error_tag or "error");

	local stream_default_ns = stream_callbacks.default_ns;

	local stream_lang = "en";

	local stack = {};
	local chardata, stanza = {};
	local stanza_size = 0;
	local non_streamns_depth = 0;
	function xml_handlers:StartElement(tagname, attr)
		if stanza and #chardata > 0 then
			-- We have some character data in the buffer
			t_insert(stanza, t_concat(chardata));
			chardata = {};
		end
		local curr_ns,name = tagname:match(ns_pattern);
		if name == "" then
			curr_ns, name = "", curr_ns;
		end

		if curr_ns ~= stream_default_ns or non_streamns_depth > 0 then
			attr.xmlns = curr_ns;
			non_streamns_depth = non_streamns_depth + 1;
		end

		for i=1,#attr do
			local k = attr[i];
			attr[i] = nil;
			local xmlk = xml_namespace[k];
			if xmlk then
				attr[xmlk] = attr[k];
				attr[k] = nil;
			end
		end

		if not stanza then --if we are not currently inside a stanza
			if lxp_supports_bytecount then
				stanza_size = self:getcurrentbytecount();
			end
			if session.notopen then
				if tagname == stream_tag then
					non_streamns_depth = 0;
					stream_lang = attr["xml:lang"] or stream_lang;
					if cb_streamopened then
						if lxp_supports_bytecount then
							cb_handleprogress(stanza_size);
							stanza_size = 0;
						end
						cb_streamopened(session, attr);
					end
				else
					-- Garbage before stream?
					cb_error(session, "no-stream", tagname);
				end
				return;
			end
			if curr_ns == "jabber:client" and name ~= "iq" and name ~= "presence" and name ~= "message" then
				cb_error(session, "invalid-top-level-element");
			end

			stanza = setmetatable({ name = name, attr = attr, tags = {} }, stanza_mt);
		else -- we are inside a stanza, so add a tag
			if lxp_supports_bytecount then
				stanza_size = stanza_size + self:getcurrentbytecount();
			end
			t_insert(stack, stanza);
			local oldstanza = stanza;
			stanza = setmetatable({ name = name, attr = attr, tags = {} }, stanza_mt);
			t_insert(oldstanza, stanza);
			t_insert(oldstanza.tags, stanza);
		end
	end

	function xml_handlers:StartCdataSection()
		if lxp_supports_bytecount then
			if stanza then
				stanza_size = stanza_size + self:getcurrentbytecount();
			else
				cb_handleprogress(self:getcurrentbytecount());
			end
		end
	end
	function xml_handlers:EndCdataSection()
		if lxp_supports_bytecount then
			if stanza then
				stanza_size = stanza_size + self:getcurrentbytecount();
			else
				cb_handleprogress(self:getcurrentbytecount());
			end
		end
	end
	function xml_handlers:CharacterData(data)
		if stanza then
			if lxp_supports_bytecount then
				stanza_size = stanza_size + self:getcurrentbytecount();
			end
			t_insert(chardata, data);
		elseif lxp_supports_bytecount then
			cb_handleprogress(self:getcurrentbytecount());
		end
	end
	function xml_handlers:EndElement(tagname)
		if lxp_supports_bytecount then
			stanza_size = stanza_size + self:getcurrentbytecount()
		end
		if non_streamns_depth > 0 then
			non_streamns_depth = non_streamns_depth - 1;
		end
		if stanza then
			if #chardata > 0 then
				-- We have some character data in the buffer
				t_insert(stanza, t_concat(chardata));
				chardata = {};
			end
			-- Complete stanza
			if #stack == 0 then
				if lxp_supports_bytecount then
					cb_handleprogress(stanza_size);
				end
				stanza_size = 0;
				if stanza.attr["xml:lang"] == nil then
					stanza.attr["xml:lang"] = stream_lang;
				end
				if tagname ~= stream_error_tag then
					cb_handlestanza(session, stanza);
				else
					cb_error(session, "stream-error", stanza);
				end
				stanza = nil;
			else
				stanza = t_remove(stack);
			end
		else
			if lxp_supports_bytecount then
				cb_handleprogress(stanza_size);
			end
			if cb_streamclosed then
				cb_streamclosed(session);
			end
		end
	end

	local function restricted_handler(parser)
		cb_error(session, "parse-error", "restricted-xml", "Restricted XML, see RFC 6120 section 11.1.");
		if not parser.stop or not parser:stop() then
			error("Failed to abort parsing");
		end
	end

	if lxp_supports_xmldecl then
		function xml_handlers:XmlDecl(version, encoding, standalone)
			if lxp_supports_bytecount then
				cb_handleprogress(self:getcurrentbytecount());
			end
			if (encoding and encoding:lower() ~= "utf-8")
			or (standalone == "no")
			or (version and version ~= "1.0") then
				return restricted_handler(self);
			end
		end
	end
	if lxp_supports_doctype then
		xml_handlers.StartDoctypeDecl = restricted_handler;
	end
	xml_handlers.Comment = restricted_handler;
	xml_handlers.ProcessingInstruction = restricted_handler;

	local function reset()
		stanza, chardata, stanza_size = nil, {}, 0;
		stack = {};
	end

	local function set_session(stream, new_session) -- luacheck: ignore 212/stream
		session = new_session;
	end

	return xml_handlers, { reset = reset, set_session = set_session };
end

local function new(session, stream_callbacks, stanza_size_limit)
	-- Used to track parser progress (e.g. to enforce size limits)
	local n_outstanding_bytes = 0;
	local handle_progress;
	if lxp_supports_bytecount then
		function handle_progress(n_parsed_bytes)
			n_outstanding_bytes = n_outstanding_bytes - n_parsed_bytes;
		end
		stanza_size_limit = stanza_size_limit or default_stanza_size_limit;
	elseif stanza_size_limit then
		error("Stanza size limits are not supported on this version of LuaExpat")
	end

	local handlers, meta = new_sax_handlers(session, stream_callbacks, handle_progress);
	local parser = new_parser(handlers, ns_separator, false);
	local parse = parser.parse;

	function session.open_stream(session, from, to) -- luacheck: ignore 432/session
		local send = session.sends2s or session.send;

		local attr = {
			["xmlns:stream"] = "http://etherx.jabber.org/streams",
			["xml:lang"] = "en",
			xmlns = stream_callbacks.default_ns,
			version = session.version and (session.version > 0 and "1.0" or nil),
			id = session.streamid or "",
			from = from or session.host, to = to,
		};
		if session.stream_attrs then
			session:stream_attrs(from, to, attr)
		end
		send("<?xml version='1.0'?>"..st.stanza("stream:stream", attr):top_tag());
		return true;
	end

	return {
		reset = function ()
			parser = new_parser(handlers, ns_separator, false);
			parse = parser.parse;
			n_outstanding_bytes = 0;
			meta.reset();
		end,
		feed = function (self, data) -- luacheck: ignore 212/self
			if lxp_supports_bytecount then
				n_outstanding_bytes = n_outstanding_bytes + #data;
			end
			local _parser = parser;
			local ok, err = parse(_parser, data);
			if lxp_supports_bytecount and n_outstanding_bytes > stanza_size_limit then
				return nil, "stanza-too-large";
			end
			if parser ~= _parser then
				_parser:parse();
				_parser:close();
			end
			return ok, err;
		end,
		set_session = meta.set_session;
		set_stanza_size_limit = function (_, new_stanza_size_limit)
			stanza_size_limit = new_stanza_size_limit;
		end;
	};
end

return {
	ns_separator = ns_separator;
	ns_pattern = ns_pattern;
	new_sax_handlers = new_sax_handlers;
	new = new;
};