Software /
code /
prosody-modules
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; +};