changeset 14138:c1469296190d

Merge 13.0->trunk
author Matthew Wild <mwild1@gmail.com>
date Fri, 17 Apr 2026 14:35:32 +0100
parents 7008869fcce2 (current diff) 368df9b2d511 (diff)
children 5aacae4b2ccf
files net/server_epoll.lua plugins/mod_admin_shell.lua plugins/mod_bosh.lua plugins/mod_c2s.lua plugins/mod_s2s.lua util/startup.lua
diffstat 12 files changed, 220 insertions(+), 62 deletions(-) [+]
line wrap: on
line diff
--- a/core/configmanager.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/core/configmanager.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -90,7 +90,7 @@
 	config_format = config_format or filename:match("%w+$");
 
 	if config_format == "lua" then
-		local f, err = io.open(filename);
+		local f, err, errno = io.open(filename);
 		if f then
 			local new_config = setmetatable({ ["*"] = { } }, config_mt);
 			local ok, err = parser.load(f:read("*a"), filename, new_config);
@@ -100,7 +100,7 @@
 			end
 			return ok, "parser", err;
 		end
-		return f, "file", err;
+		return f, "file", err, errno;
 	end
 
 	if not config_format then
--- a/net/server_epoll.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/net/server_epoll.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -154,9 +154,8 @@
 local closedtimers = {};
 
 local function closetimer(id)
-	if timers:remove(id) then
-		closedtimers[id] = true;
-	end
+	closedtimers[id] = true;
+	timers:remove(id);
 end
 
 local function reschedule(id, time)
@@ -697,6 +696,10 @@
 	-- make sure tls sockets aren't put in blocking mode
 	if self.conn.shutdown and self._tls then self.conn:shutdown(); end
 	self:del();
+	if self._pausefor then
+		closetimer(self._pausefor);
+		self._pausefor = nil;
+	end
 	self:setwritetimeout(false);
 	self:setreadtimeout(false);
 	self.onreadable = noop;
@@ -961,6 +964,9 @@
 	self:set(false);
 	self._pausefor = addtimer(t, function ()
 		self._pausefor = nil;
+		if not self.conn then
+			return;
+		end
 		self:set(true);
 		self:noise("Resuming after pause");
 		if self.conn and self.conn:dirty() then
--- a/plugins/mod_admin_shell.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/plugins/mod_admin_shell.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -421,6 +421,10 @@
 		end
 	end
 
+	if (source or line):match("^%w+$") and def_env[source or line] then
+		source, line = nil, "help:"..(source or line).."();";
+	end
+
 	local chunkname = "=console";
 	-- luacheck: ignore 311/err
 	local chunk, err = envload(source or ("return "..line), chunkname, env);
--- a/plugins/mod_bosh.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/plugins/mod_bosh.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -45,7 +45,7 @@
 
 local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure");
 local cross_domain = module:get_option("cross_domain_bosh");
-local stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024*256, 10000);
+local unauthed_stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 10000, 1000);
 
 if cross_domain ~= nil then
 	module:log("info", "The 'cross_domain_bosh' option has been deprecated");
@@ -124,8 +124,9 @@
 	local body = request.body;
 
 	local context = { request = request, response = response, notopen = true };
-	local stream = new_xmpp_stream(context, stream_callbacks, stanza_size_limit);
+	local stream = new_xmpp_stream(context, stream_callbacks, unauthed_stanza_size_limit);
 	response.context = context;
+	context.stream = stream;
 
 	local headers = response.headers;
 	headers.content_type = "text/xml; charset=utf-8";
@@ -336,6 +337,7 @@
 		session.bosh_responses = cache.new(BOSH_HOLD + 1):table();
 		session.requests = {};
 		session.send_buffer = {};
+		session.stanza_size_limit = unauthed_stanza_size_limit;
 		session.reset_stream = bosh_reset_stream;
 		session.close = bosh_close_stream;
 		session.dispatch_stanza = core_process_stanza;
@@ -411,6 +413,11 @@
 	end
 
 	session.conn = request.conn;
+	session.stream = context.stream;
+
+	if session.stanza_size_limit then
+		session.stream:set_stanza_size_limit(session.stanza_size_limit);
+	end
 
 	if session.rid then
 		local diff = rid - session.rid;
@@ -548,6 +555,7 @@
 end
 
 function module.add_host(module)
+	module:depends("c2s"); -- updates stanza_size_limit on authentication
 	module:depends("http");
 	module:provides("http", {
 		default_path = "/http-bind";
--- a/plugins/mod_c2s.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/plugins/mod_c2s.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -8,7 +8,9 @@
 
 module:set_global();
 
-local add_task = require "prosody.util.timer".add_task;
+local timer = require "prosody.util.timer";
+local add_task = timer.add_task;
+local stop_task = timer.stop;
 local new_xmpp_stream = require "prosody.util.xmppstream".new;
 local nameprep = require "prosody.util.encodings".stringprep.nameprep;
 local sessionmanager = require "prosody.core.sessionmanager";
@@ -25,10 +27,19 @@
 
 local log = module._log;
 
+local function stop_session_timers(session)
+	if session.c2s_timeout then
+		stop_task(session.c2s_timeout);
+		session.c2s_timeout = nil;
+	end
+end
+
 local c2s_timeout = module:get_option_period("c2s_timeout", "5 minutes");
 local stream_close_timeout = module:get_option_period("c2s_close_timeout", 5);
 local opt_keepalives = module:get_option_boolean("c2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
-local stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024*256,10000);
+local unauthed_stanza_size_limit = module:get_option_integer("c2s_unauthed_stanza_size_limit", 10000,1000);
+local authed_stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024*256,10000);
+local max_child_elements = module:get_option_integer("c2s_max_child_elements", 25000, 0);
 
 local advertised_idle_timeout = 14*60; -- default in all net.server implementations
 local network_settings = module:get_option("network_settings");
@@ -137,11 +148,11 @@
 	local features = st.stanza("stream:features");
 	hosts[session.host].events.fire_event("stream-features", { origin = session, features = features, stream = attr });
 	if features.tags[1] or session.full_jid then
-		if stanza_size_limit or advertised_idle_timeout then
+		if session.stanza_size_limit or advertised_idle_timeout then
 			features:reset();
 			local limits = features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" });
-			if stanza_size_limit then
-				limits:text_tag("max-bytes", string.format("%d", stanza_size_limit));
+			if session.stanza_size_limit then
+				limits:text_tag("max-bytes", string.format("%d", session.stanza_size_limit));
 			end
 			if advertised_idle_timeout then
 				limits:text_tag("idle-seconds", string.format("%d", advertised_idle_timeout));
@@ -215,6 +226,7 @@
 	context:fire_event("pre-session-close", close_event_payload);
 
 	reason = close_event_payload.reason;
+	stop_session_timers(session);
 	if session.conn then
 		if session.notopen then
 			session:open_stream();
@@ -355,7 +367,11 @@
 
 	session.close = session_close;
 
-	local stream = new_xmpp_stream(session, stream_callbacks, stanza_size_limit);
+	session.stanza_size_limit = unauthed_stanza_size_limit;
+
+	local stream = new_xmpp_stream(session, stream_callbacks, session.stanza_size_limit, {
+		max_elements = max_child_elements;
+	});
 	session.stream = stream;
 	session.notopen = true;
 
@@ -409,6 +425,8 @@
 	session.dispatch_stanza = stream_callbacks.handlestanza;
 
 	sessions[conn] = session;
+
+	module:fire_event("c2s-connected", { conn = conn, session = session });
 end
 
 function listener.onincoming(conn, data)
@@ -422,6 +440,7 @@
 	local session = sessions[conn];
 	if session then
 		(session.log or log)("info", "Client disconnected: %s", err or "connection closed");
+		stop_session_timers(session);
 		sm_destroy_session(session, err);
 		session.conn = nil;
 		sessions[conn]  = nil;
@@ -463,6 +482,13 @@
 
 function module.add_host(module)
 	module:hook("c2s-read-timeout", keepalive, -1);
+	module:hook("authentication-success", function(event)
+		local session = event.session;
+		if session.stream then
+			session.stanza_size_limit = authed_stanza_size_limit;
+			session.stream:set_stanza_size_limit(session.stanza_size_limit);
+		end
+	end);
 end
 
 module:hook("c2s-read-timeout", keepalive, -1);
--- a/plugins/mod_cloud_notify.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/plugins/mod_cloud_notify.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -74,8 +74,7 @@
 -- Forward declarations, as both functions need to reference each other
 local handle_push_success, handle_push_error;
 
-function handle_push_error(event)
-	local stanza = event.stanza;
+function handle_push_error(stanza)
 	local error_type, condition, error_text = stanza:get_error();
 	local node = id2node[stanza.attr.id];
 	local identifier = id2identifier[stanza.attr.id];
@@ -117,11 +116,6 @@
 					changed = true;
 					user_push_services[push_identifier] = nil
 					push_errors[push_identifier] = nil;
-					-- unhook iq handlers for this identifier (if possible)
-					module:unhook("iq-error/host/"..stanza.attr.id, handle_push_error);
-					module:unhook("iq-result/host/"..stanza.attr.id, handle_push_success);
-					id2node[stanza.attr.id] = nil;
-					id2identifier[stanza.attr.id] = nil;
 				end
 			elseif user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and error_type == "wait" then
 				module:log("debug", "Got error <%s:%s:%s> for identifier '%s': "
@@ -141,8 +135,7 @@
 	return true;
 end
 
-function handle_push_success(event)
-	local stanza = event.stanza;
+function handle_push_success(stanza)
 	local node = id2node[stanza.attr.id];
 	local identifier = id2identifier[stanza.attr.id];
 	if node == nil then return false; end		-- unknown stanza? Ignore for now!
@@ -153,11 +146,6 @@
 		if push_identifier == identifier then
 			if user_push_services[push_identifier] and user_push_services[push_identifier].jid == from and push_errors[push_identifier] > 0 then
 				push_errors[push_identifier] = 0;
-				-- unhook iq handlers for this identifier (if possible)
-				module:unhook("iq-error/host/"..stanza.attr.id, handle_push_error);
-				module:unhook("iq-result/host/"..stanza.attr.id, handle_push_success);
-				id2node[stanza.attr.id] = nil;
-				id2identifier[stanza.attr.id] = nil;
 				module:log("debug", "Push succeeded, error count for identifier '%s' is now at %s again",
 					push_identifier, tostring(push_errors[push_identifier])
 				);
@@ -257,14 +245,6 @@
 			end
 			user_push_services[key] = nil;
 			push_errors[key] = nil;
-			for stanza_id, identifier in pairs(id2identifier) do
-				if identifier == key then
-					module:unhook("iq-error/host/"..stanza_id, handle_push_error);
-					module:unhook("iq-result/host/"..stanza_id, handle_push_success);
-					id2node[stanza_id] = nil;
-					id2identifier[stanza_id] = nil;
-				end
-			end
 		end
 	end
 	local ok = push_store:flush_to_disk(origin.username);
@@ -413,11 +393,13 @@
 				if push_errors[push_identifier] == nil then
 					push_errors[push_identifier] = 0;
 				end
-				module:hook("iq-error/host/"..stanza_id, handle_push_error);
-				module:hook("iq-result/host/"..stanza_id, handle_push_success);
 				id2node[stanza_id] = node;
 				id2identifier[stanza_id] = push_identifier;
-				module:send(push_publish);
+				module:send_iq(push_publish):next(handle_push_success, handle_push_error):finally(function ()
+					-- unhook iq handlers for this identifier (if possible)
+					id2node[stanza.attr.id] = nil;
+					id2identifier[stanza.attr.id] = nil;
+				end);
 				pushes = pushes + 1;
 			end
 		end
--- a/plugins/mod_debug_reset.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/plugins/mod_debug_reset.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -16,7 +16,7 @@
 	module:fire_event("server-resetting");
 	for _, host in ipairs(hosts) do
 		hostmanager.deactivate(host);
-		for i = 1, 4 do
+		for _ = 1, 4 do
 			-- Run a few cycles to ensure full cleanup, including
 			-- weak tables and finalizers
 			collectgarbage("collect");
--- a/plugins/mod_limits.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/plugins/mod_limits.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -1,9 +1,13 @@
 -- Because we deal with pre-authed sessions and streams we can't be host-specific
 module:set_global();
 
+local it = require "prosody.util.iterators";
 local filters = require "prosody.util.filters";
 local throttle = require "prosody.util.throttle";
 local timer = require "prosody.util.timer";
+
+local pton = require "prosody.util.net".pton;
+
 local ceil = math.ceil;
 
 local limits_cfg = module:get_option("limits", {});
@@ -50,11 +54,51 @@
 	};
 };
 
+local function get_truncated_ip(conn)
+	local session_ip = pton(conn:ip());
+	if #session_ip == 128 then
+		-- Use /64 for IPv6
+		session_ip = session_ip:sub(1, 8);
+	end
+	return session_ip;
+end
+
+local function limit_ip_sessions(session_type, max_sessions)
+	-- [packed_truncated_ip] = count
+	local current_ips = module:shared("ips."..session_type);
+	module:hook(session_type.."-connected", function (event)
+		local session_ip = get_truncated_ip(event.conn);
+		local new_count = (current_ips[session_ip] or 0) + 1;
+		current_ips[session_ip] = new_count;
+		if new_count > max_sessions then
+			module:log("warn", "Rejecting stream from %s (%d of %d current connections)", event.conn:ip(), new_count, max_sessions);
+			event.session:close({ condition = "policy-violation", text = "too many connections" });
+		end
+	end);
+
+	module:hook(session_type.."-closed", function (event)
+		local session_ip = get_truncated_ip(event.conn);
+		local new_count = (current_ips[session_ip] or 0) - 1;
+		if new_count < 0 then
+			module:log("warn", "IP counting failure");
+			new_count = 0;
+		end
+		if new_count == 0 then
+			current_ips[session_ip] = nil;
+		else
+			current_ips[session_ip] = new_count;
+		end
+	end);
+end
+
 for sess_type, sess_limits in pairs(limits_cfg) do
 	limits[sess_type] = {
 		bytes_per_second = parse_rate(sess_limits.rate, sess_type);
 		burst_seconds = parse_burst(sess_limits.burst, sess_type);
 	};
+	if sess_limits.max_connections_per_ip then
+		limit_ip_sessions(sess_type, sess_limits.max_connections_per_ip);
+	end
 end
 
 if not limits.s2sin and limits.s2s then
@@ -158,3 +202,42 @@
 
 	end
 end
+
+module:add_item("shell-command", {
+	section = "limits";
+	section_desc = "Commands to inspect rate-limiting state";
+	name = "connections";
+	desc = "View per-IP connection limit status";
+	args = {
+		{ name = "ip", type = "string" };
+	};
+	handler = function(self, ip_str) -- luacheck: ignore 212/self
+		local check_ip = pton(ip_str);
+		if not check_ip then
+			return false, "Unable to parse IP address: "..ip_str;
+		end
+		if #check_ip == 128 then
+			check_ip = check_ip:sub(1, 8);
+		end
+
+		local found, any_limits = 0;
+
+		for sess_type, sess_limits in it.sorted_pairs(limits_cfg) do
+			if sess_limits.max_connections_per_ip then
+				any_limits = true;
+				local current_ips = module:shared("ips."..sess_type);
+				local count = current_ips[check_ip] or 0;
+				self.session.print(("%s: %d (limit %d)"):format(sess_type, count or 0, sess_limits.max_connections_per_ip));
+				found = found + count;
+			end
+		end
+
+		if not any_limits then
+			return false, "No per-IP limits are configured";
+		end
+
+		return true, ("Total of %d tracked connections with this IP"):format(found);
+	end;
+});
+
+
--- a/plugins/mod_s2s.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/plugins/mod_s2s.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -41,7 +41,9 @@
 local secure_domains, insecure_domains =
 	module:get_option_set("s2s_secure_domains", {})._items, module:get_option_set("s2s_insecure_domains", {})._items;
 local require_encryption = module:get_option_boolean("s2s_require_encryption", true);
-local stanza_size_limit = module:get_option_integer("s2s_stanza_size_limit", 1024*512, 10000);
+local unauthed_stanza_size_limit = module:get_option_integer("s2s_unauthed_stanza_size_limit", 10000, 1000);
+local authed_stanza_size_limit = module:get_option_integer("s2s_stanza_size_limit", 1024*512, 10000);
+local max_child_elements = module:get_option_integer("s2s_max_child_elements", 25000, 0);
 local sendq_size = module:get_option_integer("s2s_send_queue_size", 1024*32, 1);
 local block_retries = module:get_option_boolean("s2s_block_immediate_retries", false);
 
@@ -247,6 +249,8 @@
 	end
 	host_session.version = 1;
 
+	host_session.stanza_size_limit = unauthed_stanza_size_limit;
+
 	-- Store in buffer
 	host_session.bounce_sendq = bounce_sendq;
 	host_session.sendq = queue.new(sendq_size);
@@ -291,10 +295,11 @@
 	module:hook("route/remote", route_to_existing_session, -1);
 	module:hook("route/remote", route_to_new_session, -10);
 	module:hook("s2sout-stream-features", function (event)
-		if not (stanza_size_limit or advertised_idle_timeout) then return end
+		local session = event.origin;
+		if not (session.stanza_size_limit or advertised_idle_timeout) then return end
 		local limits = event.features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" })
-		if stanza_size_limit then
-			limits:text_tag("max-bytes", string.format("%d", stanza_size_limit));
+		if session.stanza_size_limit then
+			limits:text_tag("max-bytes", string.format("%d", session.stanza_size_limit));
 		end
 		if advertised_idle_timeout then
 			limits:text_tag("idle-seconds", string.format("%d", advertised_idle_timeout));
@@ -436,6 +441,9 @@
 		return false;
 	end
 
+	session.stanza_size_limit = authed_stanza_size_limit;
+	session.stream:set_stanza_size_limit(session.stanza_size_limit);
+
 	if session.incoming and host then
 		if not session.hosts[host] then session.hosts[host] = {}; end
 		session.hosts[host].authed = true;
@@ -594,11 +602,11 @@
 			end
 
 			if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then
-				if stanza_size_limit or advertised_idle_timeout then
+				if session.stanza_size_limit or advertised_idle_timeout then
 					features:reset();
 					local limits = features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" });
-					if stanza_size_limit then
-						limits:text_tag("max-bytes", string.format("%d", stanza_size_limit));
+					if session.stanza_size_limit then
+						limits:text_tag("max-bytes", string.format("%d", session.stanza_size_limit));
 					end
 					if advertised_idle_timeout then
 						limits:text_tag("idle-seconds", string.format("%d", advertised_idle_timeout));
@@ -815,7 +823,9 @@
 
 -- Session initialization logic shared by incoming and outgoing
 local function initialize_session(session)
-	local stream = new_xmpp_stream(session, stream_callbacks, stanza_size_limit);
+	local stream = new_xmpp_stream(session, stream_callbacks, session.stanza_size_limit, {
+		max_elements = max_child_elements;
+	});
 
 	session.thread = runner(function (item)
 		if st.is_stanza(item) then
@@ -933,7 +943,7 @@
 		module:fire_event("s2sout-connected", { session = session })
 		session:open_stream(session.from_host, session.to_host);
 	end
-	module:fire_event("s2s-connected", { session = session })
+	module:fire_event("s2s-connected", { session = session, conn = conn })
 	session.ip = conn:ip();
 end
 
--- a/util/prosodyctl.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/util/prosodyctl.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -14,7 +14,6 @@
 local usermanager = require "prosody.core.usermanager";
 local interpolation = require "prosody.util.interpolation";
 local signal = require "prosody.util.signal";
-local set = require "prosody.util.set";
 local path = require"prosody.util.paths";
 local lfs = require "lfs";
 local type = type;
@@ -134,8 +133,7 @@
 
 	pidfile = config.resolve_relative_path(prosody.paths.data, pidfile);
 
-	local modules_disabled = set.new(config.get("*", "modules_disabled"));
-	if prosody.platform ~= "posix" or modules_disabled:contains("posix") then
+	if prosody.platform ~= "posix" then
 		return false, "no-posix";
 	end
 
--- a/util/startup.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/util/startup.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -101,7 +101,7 @@
 	else
 		config.set_credential_fallback_mode("warn");
 	end
-	local ok, level, err = config.load(filename);
+	local ok, level, err, errno = config.load(filename);
 	if not ok then
 		print("\n");
 		print("**************************");
@@ -117,10 +117,26 @@
 			end
 			print("");
 		elseif level == "file" then
-			print("Prosody was unable to find the configuration file.");
-			print("We looked for: "..filename);
-			print("A sample config file is included in the Prosody download called prosody.cfg.lua.dist");
-			print("Copy or rename it to prosody.cfg.lua and edit as necessary.");
+			local err_kind;
+			if errno == 2 then
+				err_kind = "find";
+			elseif errno == 13 then
+				err_kind = "access";
+			else err_kind = "open";
+			end
+			print(("Prosody was unable to %s the configuration file:"):format(err_kind));
+			print(filename);
+			print("");
+			if err_kind == "access" then
+				print(prosody.process_type.." does not have permission to open the file.");
+				print("Try running the command again as a user with permission to read the config file.");
+			elseif err_kind == "find" then
+				print("A sample config file is included in the Prosody download called prosody.cfg.lua.dist");
+				print("Copy or rename it to "..filename.." and edit as necessary.");
+			else
+				print(err);
+			end
+			print("");
 		end
 		print("More help on configuring Prosody can be found at https://prosody.im/doc/configure");
 		print("Good luck!");
--- a/util/xmppstream.lua	Thu Apr 02 20:54:46 2026 +0100
+++ b/util/xmppstream.lua	Fri Apr 17 14:35:32 2026 +0100
@@ -22,7 +22,7 @@
 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 default_stanza_size_limit = 10000; -- Minimum stanza size limit
 
 local _ENV = nil;
 -- luacheck: std none
@@ -43,7 +43,7 @@
 
 local function dummy_cb() end
 
-local function new_sax_handlers(session, stream_callbacks, cb_handleprogress)
+local function new_sax_handlers(session, stream_callbacks, cb_handleprogress, max_elements)
 	local xml_handlers = {};
 
 	local cb_streamopened = stream_callbacks.streamopened;
@@ -69,6 +69,7 @@
 	local stack = {};
 	local chardata, stanza = {};
 	local stanza_size = 0;
+	local child_quota = 0;
 	local non_streamns_depth = 0;
 	function xml_handlers:StartElement(tagname, attr)
 		if stanza and #chardata > 0 then
@@ -86,6 +87,18 @@
 			non_streamns_depth = non_streamns_depth + 1;
 		end
 
+		if stanza and child_quota then
+			-- Check stanza complexity limits
+			child_quota = child_quota - 1;
+			if child_quota <= 0 then
+				cb_error(session, "parse-error", "resource-constraint", "Stanza exceeds permitted children");
+				if not self.stop or not self:stop() then
+					error("Failed to abort parsing");
+				end
+				return;
+			end
+		end
+
 		for i=1,#attr do
 			local k = attr[i];
 			attr[i] = nil;
@@ -122,6 +135,7 @@
 			end
 
 			stanza = setmetatable({ name = name, attr = attr, tags = {} }, stanza_mt);
+			child_quota = max_elements;
 		else -- we are inside a stanza, so add a tag
 			if lxp_supports_bytecount then
 				stanza_size = stanza_size + self:getcurrentbytecount();
@@ -237,10 +251,18 @@
 		session = new_session;
 	end
 
-	return xml_handlers, { reset = reset, set_session = set_session };
+	local function set_max_elements(new_max_elements)
+		max_elements = new_max_elements;
+	end
+
+	return xml_handlers, {
+		reset = reset;
+		set_session = set_session;
+		set_max_elements = set_max_elements;
+	};
 end
 
-local function new(session, stream_callbacks, stanza_size_limit)
+local function new(session, stream_callbacks, stanza_size_limit, ex)
 	-- Used to track parser progress (e.g. to enforce size limits)
 	local n_outstanding_bytes = 0;
 	local handle_progress;
@@ -253,8 +275,10 @@
 		error("Stanza size limits are not supported on this version of LuaExpat")
 	end
 
+	local max_elements = ex and ex.max_elements;
+
 	local handlers, meta = new_sax_handlers(session, stream_callbacks, handle_progress);
-	local parser = new_parser(handlers, ns_separator, false);
+	local parser = new_parser(handlers, ns_separator, false, max_elements);
 	local parse = parser.parse;
 
 	function session.open_stream(session, from, to) -- luacheck: ignore 432/session
@@ -298,8 +322,9 @@
 			return ok, err;
 		end,
 		set_session = meta.set_session;
-		set_stanza_size_limit = function (_, new_stanza_size_limit)
+		set_stanza_size_limit = function (_, new_stanza_size_limit, new_max_elements)
 			stanza_size_limit = new_stanza_size_limit;
+			meta.set_max_elements(new_max_elements);
 		end;
 	};
 end