Changeset

6161:99860e1b817d

mod_dnsbl: Flag accounts registered by IPs matching blocklists
author Matthew Wild <mwild1@gmail.com>
date Wed, 22 Jan 2025 18:04:26 +0000
parents 6160:4887f68130c0
children 6162:a58fb6a05412
files mod_dnsbl/README.markdown mod_dnsbl/mod_dnsbl.lua
diffstat 2 files changed, 360 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_dnsbl/README.markdown	Wed Jan 22 18:04:26 2025 +0000
@@ -0,0 +1,112 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: 'Flag accounts registered by IPs matching blocklists'
+depends:
+  - mod_anti_spam
+---
+
+This module is designed for servers with public registration enabled, and
+makes it easier to identify accounts that have been registered by potentially
+"bad" IP addresses, e.g. those that are likely to be used by spam bots.
+
+**Note:** Running a Prosody instance with public registration enabled opens up
+your server as a potential relay for spam and abuse, which can have a negative
+impact on your server and the network as a whole. We do not recommended it
+unless you have prior experience operating public internet services and are
+prepared for the time and effort necessary to tackle any issues. For other
+advice, see the Prosody documentation on [public servers](https://prosody.im/doc/public_servers).
+
+## How does it work?
+
+When a user account is registered on your server, this module checks the user's
+IP address against a list of configured blocklists. If a match is found, it
+flags the account using [mod_flags].
+
+Flags can be reviewed and managed by using the mod_flags commands and flagged
+accounts can be automatically restricted, e.g. by mod_firewall or similar.
+
+This module supports two kinds of block lists:
+
+- DNS blocklists (DNSBLs)
+- Text files, with one IP/subnet per line
+
+## Configuration
+
+**Note:** mod_dnsbl requires mod_anti_spam to be installed, but it does not
+need to be enabled or loaded (only some code is shared). mod_flags is also
+required, and this will be automatically loaded if not specified in the
+config file.
+
+The main configuration option is `dnsbls`, a list of DNSBL addresses:
+
+```lua
+dnsbls = {
+  "dnsbl.dronebl.org";
+  "cbl.abuseat.org";
+}
+```
+
+You can set a message to be sent to users who register from a matched IP
+address:
+
+```lua
+dnsbl_message = "Your IP address has been detected on a block list. Some functionality may be restricted."
+```
+
+You can change the default flag that is applied to accounts:
+
+```lua
+dnsbl_flag = "dnsbl_hit"
+```
+
+### File-based blocklists
+
+As well as real DNSBLs, you can also put file-based blocklists here, by
+prefixing `@` to a filesystem path (Prosody must have read permission to
+access the file):
+
+```lua
+dnsbls = {
+  "dnsbl.dronebl.org";
+  "@/etc/prosody/ip_blocklist.txt";
+}
+```
+
+The file must contain a single IP address or subnet on each line, though blank
+lines and comments are ignored. For example:
+
+```
+# This is a comment
+203.0.113.0/24
+2001:db8:7894::/64
+```
+
+File-based lists are automatically reloaded when you reload Prosody's
+configuration.
+
+### Advanced configuration
+
+You can override the flag and message on a per-blocklist basis with a slightly
+more detailed configuration syntax:
+
+```lua
+dnsbls = {
+  ["dnsbl.dronebl.org"] = {
+    flag = "dnsbl_hit";
+    message = "Your account is restricted because your IP address has been detected as running an open proxy. For more information see https://dronebl.org/lookup?ip={registration.ip}";
+  };
+  ["@/etc/prosody/ip_blocklist.txt"] = {
+    flag = "local_blocklist";
+    message = "Your account is restricted";
+  };
+}
+```
+
+## Compatibility
+
+Compatible with Prosody 0.12 and later.
+
+If you are using Prosody 0.12, make sure you install mod_flags from the
+community module repository. If you are using a later version, mod_flags is
+already included with Prosody.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_dnsbl/mod_dnsbl.lua	Wed Jan 22 18:04:26 2025 +0000
@@ -0,0 +1,248 @@
+local lfs = require "lfs";
+
+local adns = require "net.adns";
+local it = require "util.iterators";
+local parse_cidr = require "util.ip".parse_cidr;
+local parse_ip = require "util.ip".new_ip;
+local promise = require "util.promise";
+local set = require "util.set";
+local st = require "util.stanza";
+
+local render_message = require "util.interpolation".new("%b{}", function (s)
+	return s;
+end);
+
+local trie = module:require("mod_anti_spam/trie");
+
+local dnsbls_config_raw = module:get_option("dnsbls");
+local default_dnsbl_flag = module:get_option_string("dnsbl_flag", "dnsbl_hit");
+local default_dnsbl_message = module:get_option("dnsbl_message");
+
+if not dnsbls_config_raw then
+	module:log_status("error", "No 'dnsbls' in config file");
+	return;
+end
+
+local dnsbls = set.new();
+local dnsbls_config = {};
+
+for k, v in ipairs(dnsbls_config_raw) do
+	local dnsbl_name, dnsbl_config;
+	if type(k) == "string" then
+		dnsbl_name = k;
+		dnsbl_config = v;
+	else
+		dnsbl_name = v;
+		dnsbl_config = {};
+	end
+	dnsbls:add(dnsbl_name);
+	dnsbls_config[dnsbl_name] = dnsbl_config;
+end
+
+local function read_dnsbl_file(filename)
+	local t = trie.new();
+	local f, err = io.open(filename);
+	if not f then
+		module:log("error", "Failed to read file: %s", err);
+		return t;
+	end
+
+	local n_line, n_added = 0, 0;
+	for line in f:lines() do
+		n_line = n_line + 1;
+		line = line:gsub("#.+$", ""):match("^%s*(.-)%s*$");
+		if line == "" then -- luacheck: ignore 542
+			-- Skip
+		else
+			local parsed_ip, parsed_bits = parse_cidr(line);
+			if not parsed_ip then
+				-- Skip
+				module:log("warn", "Failed to parse IP/CIDR on %s:%d", filename, n_line);
+			else
+				if not parsed_bits then
+					-- Default to full length of IP address
+					parsed_bits = #parsed_ip.packed * 8;
+				end
+				t:add_subnet(parsed_ip, parsed_bits);
+				n_added = n_added + 1;
+			end
+		end
+	end
+
+	module:log("info", "Loaded %d entries from %s", n_added, filename);
+
+	return t;
+end
+
+local ipsets = {};
+local ipsets_last_updated = {};
+
+function reload_file_dnsbls()
+	for dnsbl in dnsbls do
+		if dnsbl:byte(1) == 64 then -- '@'
+			local filename = dnsbl:sub(2);
+			local file_last_updated = lfs.attributes(filename, "change");
+			if (ipsets_last_updated[dnsbl] or 0) < file_last_updated then
+				ipsets[dnsbl] = read_dnsbl_file(filename);
+				ipsets_last_updated[dnsbl] = file_last_updated;
+			end
+		end
+	end
+end
+
+module:hook_global("config-reloaded", reload_file_dnsbls);
+reload_file_dnsbls();
+
+local mod_flags = module:depends("flags");
+
+local function reverse(ip, suffix)
+	local a,b,c,d = ip:match("^(%d+).(%d+).(%d+).(%d+)$");
+	if not a then return end
+	return ("%d.%d.%d.%d.%s"):format(d,c,b,a, suffix);
+end
+
+function check_dnsbl(ip_address, dnsbl, callback, ud)
+	if dnsbl:byte(1) == 64 then -- '@'
+		local parsed_ip = parse_ip(ip_address);
+		if not parsed_ip then
+			module:log("warn", "Failed to parse IP address: %s", ip_address);
+			callback(ud, false, dnsbl);
+			return;
+		end
+		callback(ud, not not ipsets[dnsbl]:contains_ip(parsed_ip), dnsbl);
+		return;
+	else
+		if ip_address:sub(1,7):lower() == "::ffff:" then
+			ip_address = ip_address:sub(8);
+		end
+
+		local rbl_ip = reverse(ip_address, dnsbl);
+		if not rbl_ip then return; end
+
+		module:log("debug", "Sending DNSBL lookup for %s", ip_address);
+		adns.lookup(function (reply)
+			local hit = not not (reply and reply[1]);
+			module:log("debug", "Received DNSBL result for %s: %s", ip_address, hit and "present" or "absent");
+			callback(ud, hit, dnsbl);
+		end, rbl_ip);
+	end
+end
+
+local function handle_dnsbl_register_result(registration_event, hit, dnsbl)
+	if not hit then return; end
+
+	if registration_event.dnsbl_match then return; end
+	registration_event.dnsbl_match = true;
+
+	local username = registration_event.username;
+	local flag = dnsbls_config[dnsbl].flag or default_dnsbl_flag;
+
+	module:log("info", "Flagging %s for user %s registered from %s matching %s", flag, username, registration_event.ip, dnsbl);
+
+	mod_flags:add_flag(username, flag, "Matched "..dnsbl);
+
+	local msg = dnsbls_config[dnsbl].message or default_dnsbl_message;
+
+	if msg then
+		module:log("debug", "Sending warning message to %s", username);
+		local msg_stanza = st.message(
+			{
+				to = username.."@"..module.host;
+				from = module.host;
+			},
+			render_message(msg, { registration = registration_event })
+		);
+		module:send(msg_stanza);
+	end
+end
+
+module:hook("user-registered", function (event)
+	local session = event.session;
+	local ip = event.ip or (session and session.ip);
+	if not ip then return; end
+
+	if not event.ip then
+		event.ip = ip;
+	end
+
+	for dnsbl in dnsbls do
+		check_dnsbl(ip, dnsbl, handle_dnsbl_register_result, event);
+	end
+end);
+
+module:add_item("account-trait", {
+	name = "register-dnsbl-hit";
+	prob_bad_true = 0.6;
+	prob_bad_false = 0.4;
+});
+
+module:hook("get-account-traits", function (event)
+	event.traits["register-dnsbl-hit"] = mod_flags.has_flag(event.username, default_dnsbl_flag);
+end);
+
+module:add_item("shell-command", {
+	section = "dnsbl";
+	section_desc = "Manage DNS blocklists";
+	name = "lists";
+	desc = "Show all lists currently in use on the specified host";
+	args = {
+		{ name = "host", type = "string" };
+	};
+	host_selector = "host";
+	handler = function(self, host) --luacheck: ignore 212/self 212/host
+		local count = 0;
+		for list in dnsbls do
+			count = count + 1;
+			self.session.print(list);
+		end
+		return true, ("%d lists"):format(count);
+	end;
+});
+
+module:add_item("shell-command", {
+	section = "dnsbl";
+	section_desc = "Manage DNS blocklists";
+	name = "check";
+	desc = "Check an IP against the configured block lists";
+	args = {
+		{ name = "host", type = "string" };
+		{ name = "ip_address", type = "string" };
+	};
+	host_selector = "host";
+	handler = function(self, host, ip_address) --luacheck: ignore 212/self 212/host
+		local parsed_ip = parse_ip(ip_address);
+		if not parsed_ip then
+			return false, "Failed to parse IP address";
+		end
+
+		local matches, total = 0, 0;
+
+		local promises = {};
+
+		for dnsbl in dnsbls do
+			total = total + 1;
+			promises[dnsbl] = promise.new(function (resolve)
+				check_dnsbl(parsed_ip, dnsbl, resolve, true);
+			end);
+		end
+
+		return promise.all_settled(promises):next(function (results)
+			for dnsbl, result in it.sorted_pairs(results) do
+				local msg;
+				if result.status == "fulfilled" then
+					if result.value then
+						msg = "[X]";
+						matches = matches + 1;
+					else
+						msg = "[ ]";
+					end
+				else
+					msg = "[?]";
+				end
+
+				print(msg, dnsbl);
+			end
+			return ("Found in %d of %d lists"):format(matches, total);
+		end);
+	end;
+});