diff --git a/test/antispam_tests.erl b/test/antispam_tests.erl new file mode 100644 index 000000000..d7ce196c0 --- /dev/null +++ b/test/antispam_tests.erl @@ -0,0 +1,152 @@ +%%%------------------------------------------------------------------- +%%% Author : Stefan Strigler +%%% Created : 8 May 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 ProcessOne +%%% +%%% This program is free software; you can redistribute it and/or +%%% modify it under the terms of the GNU General Public License as +%%% published by the Free Software Foundation; either version 2 of the +%%% License, or (at your option) any later version. +%%% +%%% This program is distributed in the hope that it will be useful, +%%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +%%% General Public License for more details. +%%% +%%% You should have received a copy of the GNU General Public License along +%%% with this program; if not, write to the Free Software Foundation, Inc., +%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +%%% +%%%---------------------------------------------------------------------- +-module(antispam_tests). + +-compile(export_all). + +-import(suite, [recv_presence/1, send_recv/2, my_jid/1, muc_room_jid/1, + send/2, recv_message/1, recv_iq/1, muc_jid/1, + alt_room_jid/1, wait_for_slave/1, wait_for_master/1, + disconnect/1, put_event/2, get_event/1, peer_muc_jid/1, + my_muc_jid/1, get_features/2, set_opt/3]). +-include("suite.hrl"). + +%%%=================================================================== +%%% API +%%%=================================================================== +%%%=================================================================== +%%% Single tests +%%%=================================================================== +single_cases() -> + {antispam_single, [sequence], + [single_test(spam_files), + single_test(blocked_domains), + single_test(jid_cache), + single_test(rtbl_domains)]}. + +spam_files(Config) -> + Host = ?config(server, Config), + To = my_jid(Config), + + SpamJID = jid:make(<<"spammer_jid">>, <<"localhost">>, <<"spam_client">>), + SpamJIDMsg = #message{from = SpamJID, to = To, type = chat, body = [#text{data = <<"hello world">>}]}, + is_spam(SpamJIDMsg), + + Spammer = jid:make(<<"spammer">>, <<"localhost">>, <<"spam_client">>), + NoSpamMsg = #message{from = Spammer, to = To, type = chat, body = [#text{data = <<"hello world">>}]}, + is_not_spam(NoSpamMsg), + SpamMsg = #message{from = Spammer, to = To, type = chat, body = [#text{data = <<"hello world\nhttps://spam.domain.url">>}]}, + is_spam(SpamMsg), + %% now check this mischief is in jid_cache + is_spam(NoSpamMsg), + mod_antispam:drop_from_spam_filter_cache(Host, jid:to_string(Spammer)), + is_not_spam(NoSpamMsg), + + ?retry(100, 10, + ?match(true, (has_spam_domain(<<"spam_domain.org">>))(Host))), + + SpamDomain = jid:make(<<"spammer">>, <<"spam_domain.org">>, <<"spam_client">>), + SpamDomainMsg = #message{from = SpamDomain, to = To, type = chat, body = [#text{data = <<"hello world">>}]}, + is_spam(SpamDomainMsg), + ?match({ok, _}, mod_antispam:remove_blocked_domain(Host, <<"spam_domain.org">>)), + ?match([], mod_antispam:get_blocked_domains(Host)), + is_not_spam(SpamDomainMsg), + disconnect(Config). + +blocked_domains(Config) -> + Host = ?config(server, Config), + ?match([], mod_antispam:get_blocked_domains(Host)), + SpamFrom = jid:make(<<"spammer">>, <<"spam.domain">>, <<"spam_client">>), + To = my_jid(Config), + Msg = #message{from = SpamFrom, to = To, type = chat, body = [#text{data = <<"hello world">>}]}, + is_not_spam(Msg), + ?match({ok, _}, mod_antispam:add_blocked_domain(<<"global">>, <<"spam.domain">>)), + is_spam(Msg), + Vhosts = [H || H <- ejabberd_option:hosts(), gen_mod:is_loaded(H, mod_antispam)], + NumVhosts = length(Vhosts), + ?match(NumVhosts, length(lists:filter(has_spam_domain(<<"spam.domain">>), Vhosts))), + ?match({ok, _}, mod_antispam:remove_blocked_domain(Host, <<"spam.domain">>)), + ?match([], mod_antispam:get_blocked_domains(Host)), + is_not_spam(Msg), + ?match(NumVhosts, length(lists:filter(has_spam_domain(<<"spam.domain">>), Vhosts)) + 1), + ?match({ok, _}, mod_antispam:remove_blocked_domain(<<"global">>, <<"spam.domain">>)), + ?match([], lists:filter(has_spam_domain(<<"spam.domain">>), Vhosts)), + ?match({ok, _}, mod_antispam:add_blocked_domain(Host, <<"spam.domain">>)), + ?match([Host], lists:filter(has_spam_domain(<<"spam.domain">>), Vhosts)), + is_spam(Msg), + ?match({ok, _}, mod_antispam:remove_blocked_domain(Host, <<"spam.domain">>)), + is_not_spam(Msg), + disconnect(Config). + +jid_cache(Config) -> + Host = ?config(server, Config), + SpamFrom = jid:make(<<"spammer">>, Host, <<"spam_client">>), + To = my_jid(Config), + Msg = #message{from = SpamFrom, to = To, type = chat, body = [#text{data = <<"hello world">>}]}, + is_not_spam(Msg), + mod_antispam:add_to_spam_filter_cache(Host, jid:to_string(SpamFrom)), + is_spam(Msg), + mod_antispam:drop_from_spam_filter_cache(Host, jid:to_string(SpamFrom)), + is_not_spam(Msg), + disconnect(Config). + +rtbl_domains(Config) -> + Host = ?config(server, Config), + RTBLHost = jid:to_string(suite:pubsub_jid(Config)), + RTBLDomainsNode = <<"spam_source_domains">>, + OldOpts = gen_mod:get_module_opts(Host, mod_antispam), + NewOpts = maps:merge(OldOpts, #{rtbl_host => RTBLHost, rtbl_domains_node => RTBLDomainsNode}), + Owner = jid:make(?config(user, Config), ?config(server, Config), <<>>), + {result, _} = mod_pubsub:create_node(RTBLHost, ?config(server, Config), RTBLDomainsNode, Owner, <<"flat">>), + {result, _} = mod_pubsub:publish_item(RTBLHost, ?config(server, Config), RTBLDomainsNode, Owner, <<"spam.source.domain">>, + [xmpp:encode(#ps_item{id = <<"spam.source.domain">>, sub_els = []})]), + mod_antispam:reload(Host, OldOpts, NewOpts), + ?match({ok, _}, mod_antispam:remove_blocked_domain(Host, <<"spam_domain.org">>)), + ?retry(100, 10, + ?match([<<"spam.source.domain">>], mod_antispam:get_blocked_domains(Host))), + {result, _} = mod_pubsub:publish_item(RTBLHost, ?config(server, Config), RTBLDomainsNode, Owner, <<"spam.source.another">>, + [xmpp:encode(#ps_item{id = <<"spam.source.another">>, sub_els = []})]), + ?retry(100, 10, + ?match(true, (has_spam_domain(<<"spam.source.another">>))(Host))), + {result, _} = mod_pubsub:delete_item(RTBLHost, RTBLDomainsNode, Owner, <<"spam.source.another">>, true), + ?retry(100, 10, + ?match(false, (has_spam_domain(<<"spam.source.another">>))(Host))), + {result, _} = mod_pubsub:delete_node(RTBLHost, RTBLDomainsNode, Owner), + disconnect(Config). + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== +single_test(T) -> + list_to_atom("antispam_" ++ atom_to_list(T)). + +has_spam_domain(Domain) -> + fun(Host) -> + lists:member(Domain, mod_antispam:get_blocked_domains(Host)) + end. + +is_not_spam(Msg) -> + ?match({Msg, undefined}, mod_antispam:s2s_receive_packet({Msg, undefined})). + +is_spam(Spam) -> + ?match({stop, {drop, undefined}}, mod_antispam:s2s_receive_packet({Spam, undefined})). diff --git a/test/ejabberd_SUITE.erl b/test/ejabberd_SUITE.erl index b0e47cde6..4b2a7fccc 100644 --- a/test/ejabberd_SUITE.erl +++ b/test/ejabberd_SUITE.erl @@ -339,6 +339,10 @@ init_per_testcase(TestCase, OrigConfig) -> bind(auth(connect(Config))); "replaced" ++ _ -> auth(connect(Config)); + "antispam" ++ _ -> + Password = ?config(password, Config), + ejabberd_auth:try_register(User, Server, Password), + open_session(bind(auth(connect(Config)))); _ when IsMaster or IsSlave -> Password = ?config(password, Config), ejabberd_auth:try_register(User, Server, Password), @@ -425,6 +429,7 @@ db_tests(DB) when DB == mnesia; DB == redis -> auth_md5, presence_broadcast, last, + antispam_tests:single_cases(), webadmin_tests:single_cases(), roster_tests:single_cases(), private_tests:single_cases(), diff --git a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml index 42ab23ab2..c8585e6e4 100644 --- a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml +++ b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml @@ -6,6 +6,11 @@ define_macro: mod_announce: db_type: internal access: local + mod_antispam: + rtbl_host: pubsub.mnesia.localhost + spam_jids_file: spam_jids.txt + spam_domains_file: spam_domains.txt + spam_urls_file: spam_urls.txt mod_blocking: [] mod_caps: db_type: internal diff --git a/test/ejabberd_SUITE_data/ejabberd.redis.yml b/test/ejabberd_SUITE_data/ejabberd.redis.yml index 6ff7d7cdb..cdbc905bd 100644 --- a/test/ejabberd_SUITE_data/ejabberd.redis.yml +++ b/test/ejabberd_SUITE_data/ejabberd.redis.yml @@ -7,6 +7,11 @@ define_macro: mod_announce: db_type: internal access: local + mod_antispam: + rtbl_host: pubsub.redis.localhost + spam_jids_file: spam_jids.txt + spam_domains_file: spam_domains.txt + spam_urls_file: spam_urls.txt mod_blocking: [] mod_caps: db_type: internal diff --git a/test/ejabberd_SUITE_data/spam_domains.txt b/test/ejabberd_SUITE_data/spam_domains.txt new file mode 100644 index 000000000..c081998b7 --- /dev/null +++ b/test/ejabberd_SUITE_data/spam_domains.txt @@ -0,0 +1 @@ +spam_domain.org diff --git a/test/ejabberd_SUITE_data/spam_jids.txt b/test/ejabberd_SUITE_data/spam_jids.txt new file mode 100644 index 000000000..2b911fd82 --- /dev/null +++ b/test/ejabberd_SUITE_data/spam_jids.txt @@ -0,0 +1 @@ +spammer_jid@localhost diff --git a/test/ejabberd_SUITE_data/spam_urls.txt b/test/ejabberd_SUITE_data/spam_urls.txt new file mode 100644 index 000000000..857172d46 --- /dev/null +++ b/test/ejabberd_SUITE_data/spam_urls.txt @@ -0,0 +1 @@ +https://spam.domain.url diff --git a/test/suite.erl b/test/suite.erl index 28825b1d1..62b442580 100644 --- a/test/suite.erl +++ b/test/suite.erl @@ -51,6 +51,9 @@ init_config(Config) -> {ok, _} = file:copy(SelfSignedCertFile, filename:join([CWD, "self-signed-cert.pem"])), {ok, _} = file:copy(CAFile, filename:join([CWD, "ca.pem"])), + copy_file(Config, "spam_jids.txt"), + copy_file(Config, "spam_urls.txt"), + copy_file(Config, "spam_domains.txt"), {ok, MacrosContentTpl} = file:read_file(MacrosPathTpl), Password = <<"password!@#$%^&*()'\"`~<>+-/;:_=[]{}|\\">>, Backends = get_config_backends(), @@ -138,6 +141,11 @@ init_config(Config) -> {backends, Backends} |Config]. +copy_file(Config, File) -> + {ok, CWD} = file:get_cwd(), + DataDir = proplists:get_value(data_dir, Config), + {ok, _} = file:copy(filename:join([DataDir, File]), filename:join([CWD, File])). + copy_configtest_yml(DataDir, CWD) -> Files = filelib:wildcard(filename:join([DataDir, "configtest.yml"])), lists:foreach( @@ -906,6 +914,21 @@ receiver(NS, Owner, Socket, MRef) -> receiver(NS, Owner, Socket, MRef) end. +%% @doc Retry an action until success, at max N times with an interval +%% `Interval' +%% Shamlessly stolen (with slight adaptations) from snabbkaffee. +-spec retry(integer(), non_neg_integer(), fun(() -> Ret)) -> Ret. +retry(_, 0, Fun) -> + Fun(); +retry(Interval, N, Fun) -> + try Fun() + catch + EC:Err -> + timer:sleep(Interval), + ct:pal("retrying ~p more times, result was ~p:~p", [N, EC, Err]), + retry(Interval, N - 1, Fun) + end. + %%%=================================================================== %%% Clients puts and gets events via this relay. %%%=================================================================== diff --git a/test/suite.hrl b/test/suite.hrl index 08bb87df1..00b341cb1 100644 --- a/test/suite.hrl +++ b/test/suite.hrl @@ -89,6 +89,8 @@ -define(send_recv(Send, Recv), ?match(Recv, suite:send_recv(Config, Send))). +-define(retry(TIMEOUT, N, FUN), suite:retry(TIMEOUT, N, fun() -> FUN end)). + -define(COMMON_VHOST, <<"localhost">>). -define(MNESIA_VHOST, <<"mnesia.localhost">>). -define(REDIS_VHOST, <<"redis.localhost">>).