Changeset

6058:e905ef16efb7

mod_report_affiliations: New module for XEP-0489: Reporting Account Affiliations
author Matthew Wild <mwild1@gmail.com>
date Fri, 22 Nov 2024 19:05:49 +0000
parents 6057:cc665f343690
children 6059:25b091cbb471
files mod_report_affiliations/README.markdown mod_report_affiliations/mod_report_affiliations.lua mod_report_affiliations/traits.lib.lua
diffstat 3 files changed, 338 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_report_affiliations/README.markdown	Fri Nov 22 19:05:49 2024 +0000
@@ -0,0 +1,114 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: 'XEP-0489: Reporting Account Affiliations'
+rockspec:
+  build:
+    modules:
+      mod_report_affiliations.traits: traits.lib.lua
+---
+
+
+This module implements [XEP-0489: Reporting Account Affiliations](https://xmpp.org/extensions/xep-0489.html).
+It can help with spam on the network, especially if you run a public server
+that allows registration.
+
+## How it works
+
+Here is the scenario: you run a public server. Despite your best efforts, and
+following the [best practices](https://prosody.im/doc/public_servers), some
+spammers still occasionally manage to register on your server. Because of
+this, other servers on the network start filtering messages from all accounts
+on your server.
+
+Enabling this module will include additional information in certain kinds of
+outgoing traffic, which allows other servers to judge the sending account,
+rather than the whole server.
+
+### When is affiliation information shared?
+
+Affiliation is shared when a user on your server:
+
+- sends a message to a user that has not (yet) authorized them
+- sends a subscription request to a user
+- sends a "directed presence" to a remote JID (for example, when joining a
+  group chat).
+
+### What information is shared?
+
+The following information is included in matching traffic:
+
+- The affiliation of the account:
+  - "guest" (the account is anonymous/temporary)
+  - "registered" (the account was self-registered)
+  - "member" (the account belongs to a recognised/trusted member of the server)
+  - "admin" (the account belongs to a server administrator)
+
+For the "registered" affiliation, the following additional items are included:
+
+- When the account was created
+- The "trust level" of the account
+
+### What is the trust level?
+
+This is a score out of 100 which indicates how trusted the account is. It is
+automatically calculated, and the calculation may include various factors
+provided by installed modules. At this time, in a default installation, the
+reported value is always 50.
+
+## Configuration
+
+### Allowing queries
+
+In most cases, Prosody will automatically include the affiliation information
+when necessary. However it is also possible to provide affiliation on-demand,
+in response to queries.
+
+To avoid leaking information about the server's registered users, queries are
+restricted by default.
+
+You can configure a list of servers from which queries are permitted, by using
+the 'report_affiliations_trusted_servers' option:
+
+```lua
+report_affiliations_trusted_servers = { "rtbl.example.net" }
+```
+
+In this example, permission has been granted to an RTBL service, so that it
+can query the server and avoid adding legitimate users to the blocklist, even
+if it receives reports about them (obviously this is just an example, RTBLs
+will decide their own policies).
+
+### Tweaking roles
+
+Prosody automatically maps its standard roles to the affiliations defined by
+the XEP. If your deployment uses custom roles, you can customize the mapping
+by specifying the list of roles that should be mapped to a given affiliation.
+This can be done using the following options:
+
+- report_affiliations_admin_roles
+- report_affiliations_member_roles
+- report_affiliations_registered_roles
+- report_affiliations_anonymous_roles
+
+For example, to consider the 'company:staff' role as members, as well as the
+built-in prosody:member role, you might set the following:
+
+```lua
+report_affiliations_member_roles = { "prosody:member", "company:staff" }
+```
+
+## Compatibility
+
+Should work with 0.12, but has not been tested. 0.12 does not support the
+"member" role, so all non-anonymous/non-admin accounts will be reported as
+"registered".
+
+Tested with trunk (2024-11-22).
+
+
+------------------------------------------------------------------------
+
+**Note:** mod\_anti\_spam is not yet ready for use.
+
+------------------------------------------------------------------------
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_report_affiliations/mod_report_affiliations.lua	Fri Nov 22 19:05:49 2024 +0000
@@ -0,0 +1,166 @@
+local dt = require "util.datetime";
+local jid = require "util.jid";
+local st = require "util.stanza";
+
+local rm = require "core.rostermanager";
+local um = require "core.usermanager";
+
+local traits = module:require("traits");
+
+local xmlns_aff = "urn:xmpp:raa:0";
+
+local is_host_anonymous = module:get_option_string("authentication") == "anonymous";
+
+local trusted_servers = module:get_option_inherited_set("report_affiliations_trusted_servers", {});
+
+local roles = {
+	-- These affiliations are defined by XEP-0489, and we map a set of Prosody roles to each one
+	admin = module:get_option_set("report_affiliations_admin_roles", { "prosody:admin", "prosody:operator" });
+	member = module:get_option_set("report_affiliations_member_roles", { "prosody:member" });
+	registered = module:get_option_set("report_affiliations_registered", { "prosody:user", "prosody:registered" });
+	guest = module:get_option_set("report_affiliations_anonymous", { "prosody:guest" });
+};
+
+-- Map of role to affiliation
+local role_affs = {
+	-- [role (e.g. "prosody:guest")] = affiliation ("admin"|"member"|"registered"|"guest");
+};
+
+--Build the role->affiliation map based on the config
+for aff, aff_roles in pairs(roles) do
+	for role in aff_roles do
+		role_affs[role] = aff;
+	end
+end
+
+local account_details_store = module:open_store("account_details");
+local lastlog2_store = module:open_store("lastlog2");
+
+module:add_feature(xmlns_aff);
+module:add_feature(xmlns_aff.."#embed-presence-sub");
+module:add_feature(xmlns_aff.."#embed-presence-directed");
+
+local function get_registered_timestamp(username)
+	if um.get_account_info then
+		local ts = um.get_account_info(username, module.host);
+		if ts then return ts.created; end
+	end
+
+	local account_details = account_details_store:get(username);
+	if account_details and account_details.registered then
+		return account_details.registered;
+	end
+
+	local lastlog2 = lastlog2_store:get(username);
+	if lastlog2 and lastlog2.registered then
+		return lastlog2.registered.timestamp;
+	end
+
+	return nil;
+end
+
+local function get_trust_score(username)
+	return math.floor(100 * (1 - traits.get_probability_bad(username)));
+end
+
+
+local function get_account_type(username)
+	if is_host_anonymous then
+		return "anonymous";
+	end
+
+	if not um.get_user_role then
+		return "registered"; -- COMPAT w/0.12
+	end
+
+	local user_role = um.get_user_role(username, module.host);
+
+	return role_affs[user_role] or "registered";
+end
+
+function get_info_element(username)
+	local account_type = get_account_type(username);
+
+	local since, trust;
+
+	if account_type == "registered" then
+		since = get_registered_timestamp(username);
+		trust = get_trust_score(username);
+	end
+
+	return st.stanza("info", {
+		affiliation = account_type;
+		since = since and dt.datetime(since - (since%86400)) or nil;
+		trust = ("%d"):format(trust);
+		xmlns = xmlns_aff;
+	});
+end
+
+-- Outgoing presence
+
+local function embed_in_outgoing_presence(pres_type)
+	return function (event)
+		local origin, stanza = event.origin, event.stanza;
+
+		stanza:remove_children("info", xmlns_aff);
+
+		-- Unavailable presence is pretty harmless, and blocking it may cause
+		-- weird issues.
+		if (pres_type == "bare" and stanza.attr.type == "unavailable")
+		or (pres_type == "full" and stanza.attr.type ~= nil) then
+			return;
+		end
+
+		-- Only attach info to stanzas sent to "strangers" (users that have not
+		-- approved us to see their presence)
+		if rm.is_user_subscribed(origin.username, origin.host, stanza.attr.to) then
+			return;
+		end
+
+		local info = get_info_element(origin.username);
+		if not info then return; end
+
+		stanza:add_direct_child(info);
+	end;
+end
+
+module:hook("pre-presence/bare", embed_in_outgoing_presence("bare"));
+module:hook("pre-presence/full", embed_in_outgoing_presence("full"));
+
+-- Handle direct queries
+
+local function should_permit_query(from_jid, to_username) --luacheck: ignore 212/to_username
+	local from_node, from_host = jid.split(from_jid);
+	if from_node then
+		return false;
+	end
+
+	-- Who should we respond to?
+	-- Only respond to domains
+	-- Does user have a JID with this domain in directed presence? (doesn't work with bare JIDs)
+	-- Does this user have a JID with domain in pending subscription requests?
+
+	if trusted_servers:contains(from_host) then
+		return true;
+	end
+
+	return false;
+end
+
+module:hook("iq-get/bare/urn:xmpp:raa:0:query", function (event)
+	local origin, stanza = event.origin, event.stanza;
+	local username = jid.node(stanza.attr.to);
+
+	if not should_permit_query(stanza.attr.from, username) then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	end
+
+	local info = get_info_element(username);
+
+	local reply = st.reply(stanza)
+		:add_child(info);
+	origin.send(reply);
+
+	return true;
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_report_affiliations/traits.lib.lua	Fri Nov 22 19:05:49 2024 +0000
@@ -0,0 +1,58 @@
+local known_traits = {};
+
+local function trait_added(event)
+	local trait = event.item;
+	local name = trait.name;
+	if known_traits[name] then return; end
+
+	known_traits[name] = trait.probabilities;
+end
+
+local function trait_removed(event)
+	local trait = event.item;
+	known_traits[trait.name] = nil;
+end
+
+module:handle_items("account-trait", trait_added, trait_removed);
+
+local function bayes_probability(prior, prob_given_true, prob_given_false)
+	local numerator = prob_given_true * prior;
+	local denominator = numerator + prob_given_false * (1 - prior);
+	return numerator / denominator;
+end
+
+local function prob_is_bad(traits, prior)
+	prior = prior or 0.50;
+
+	for trait, state in pairs(traits) do
+		local probabilities = known_traits[trait];
+		if probabilities then
+			if state then
+				prior = bayes_probability(
+					prior,
+					probabilities.prob_bad_true,
+					probabilities.prob_bad_false
+				);
+			else
+				prior = bayes_probability(
+					prior,
+					1 - probabilities.prob_bad_true,
+					1 - probabilities.prob_bad_false
+				);
+			end
+		end
+	end
+
+	return prior;
+end
+
+local function get_probability_bad(username, prior)
+	local user_traits = {};
+	module:fire_event("get-account-traits", { username = username, host = module.host, traits = user_traits });
+	local result = prob_is_bad(user_traits, prior);
+	return result;
+end
+
+return {
+	get_probability_bad = get_probability_bad;
+};