diff --git a/doc/guide.tex b/doc/guide.tex index 69cc900f7..8e2b91049 100644 --- a/doc/guide.tex +++ b/doc/guide.tex @@ -72,6 +72,7 @@ \newcommand{\modconfigure}{\module{mod\_configure}} \newcommand{\moddisco}{\module{mod\_disco}} \newcommand{\modecho}{\module{mod\_echo}} +\newcommand{\modfailban}{\module{mod\_fail2ban}} \newcommand{\modhttpbind}{\module{mod\_http\_bind}} \newcommand{\modhttpfileserver}{\module{mod\_http\_fileserver}} \newcommand{\modirc}{\module{mod\_irc}} @@ -2783,6 +2784,7 @@ The following table lists all modules included in \ejabberd{}. \hline \modconfigure{} & Server configuration using Ad-Hoc & \modadhoc{} \\ \hline \ahrefloc{moddisco}{\moddisco{}} & Service Discovery (\xepref{0030}) & \\ \hline \ahrefloc{modecho}{\modecho{}} & Echoes XMPP stanzas & \\ + \hline \ahrefloc{modfail2ban}{\modfailban{}} & Bans IPs that show the malicious signs & \\ \hline \ahrefloc{modhttpbind}{\modhttpbind{}} & XMPP over Bosh service (HTTP Binding) & \\ \hline \ahrefloc{modhttpfileserver}{\modhttpfileserver{}} & Small HTTP file server & \\ \hline \ahrefloc{modirc}{\modirc{}} & IRC transport & \\ @@ -3117,6 +3119,30 @@ modules: ... \end{verbatim} +\makesubsection{modfail2ban}{\modfailban{}} +\ind{modules!\modfailban{}}\ind{modfail2ban} + +The module bans IPs that show the malicious signs. Currently only C2S authentication +failures are detected. + +Available options: +\begin{description} + \titem{c2s\_auth\_ban\_lifetime: Seconds} The lifetime of the IP ban caused by too + many C2S authentication failures. The default is 3600, i.e. one hour. + \titem{c2s\_max\_auth\_failures: Integer} The number of C2S authentication failures to + trigger the IP ban. The default is 20. +\end{description} + +Example: +\begin{verbatim} +modules: + ... + mod_fail2ban: + c2s_auth_block_lifetime: 7200 + c2s_max_auth_failures: 50 + ... +\end{verbatim} + \makesubsection{modhttpbind}{\modhttpbind{}} \ind{modules!\modhttpbind{}}\ind{modhttpbind} diff --git a/src/ejabberd_c2s.erl b/src/ejabberd_c2s.erl index 135bc7005..7750f23f3 100644 --- a/src/ejabberd_c2s.erl +++ b/src/ejabberd_c2s.erl @@ -316,33 +316,24 @@ init([{SockMod, Socket}, Opts]) -> end, ResendOnTimeout = proplists:get_bool(resend_on_timeout, Opts), IP = peerip(SockMod, Socket), - %% Check if IP is blacklisted: - case is_ip_blacklisted(IP) of - true -> - ?INFO_MSG("Connection attempt from blacklisted " - "IP: ~s (~w)", - [jlib:ip_to_list(IP), IP]), - {stop, normal}; - false -> - Socket1 = if TLSEnabled andalso - SockMod /= ejabberd_frontend_socket -> - SockMod:starttls(Socket, TLSOpts); - true -> Socket - end, - SocketMonitor = SockMod:monitor(Socket1), - StateData = #state{socket = Socket1, sockmod = SockMod, - socket_monitor = SocketMonitor, - xml_socket = XMLSocket, zlib = Zlib, tls = TLS, - tls_required = StartTLSRequired, - tls_enabled = TLSEnabled, tls_options = TLSOpts, - sid = {now(), self()}, streamid = new_id(), - access = Access, shaper = Shaper, ip = IP, - mgmt_state = StreamMgmtState, - mgmt_max_queue = MaxAckQueue, - mgmt_timeout = ResumeTimeout, - mgmt_resend = ResendOnTimeout}, - {ok, wait_for_stream, StateData, ?C2S_OPEN_TIMEOUT} - end. + Socket1 = if TLSEnabled andalso + SockMod /= ejabberd_frontend_socket -> + SockMod:starttls(Socket, TLSOpts); + true -> Socket + end, + SocketMonitor = SockMod:monitor(Socket1), + StateData = #state{socket = Socket1, sockmod = SockMod, + socket_monitor = SocketMonitor, + xml_socket = XMLSocket, zlib = Zlib, tls = TLS, + tls_required = StartTLSRequired, + tls_enabled = TLSEnabled, tls_options = TLSOpts, + sid = {now(), self()}, streamid = new_id(), + access = Access, shaper = Shaper, ip = IP, + mgmt_state = StreamMgmtState, + mgmt_max_queue = MaxAckQueue, + mgmt_timeout = ResumeTimeout, + mgmt_resend = ResendOnTimeout}, + {ok, wait_for_stream, StateData, ?C2S_OPEN_TIMEOUT}. %% Return list of all available resources of contacts, get_subscribed(FsmRef) -> @@ -366,21 +357,22 @@ wait_for_stream({xmlstreamstart, _Name, Attrs}, StateData) -> jlib:nameprep(xml:get_attr_s(<<"to">>, Attrs)); S -> S end, + Lang = case xml:get_attr_s(<<"xml:lang">>, Attrs) of + Lang1 when byte_size(Lang1) =< 35 -> + %% As stated in BCP47, 4.4.1: + %% Protocols or specifications that + %% specify limited buffer sizes for + %% language tags MUST allow for + %% language tags of at least 35 characters. + Lang1; + _ -> + %% Do not store long language tag to + %% avoid possible DoS/flood attacks + <<"">> + end, + IsBlacklistedIP = is_ip_blacklisted(StateData#state.ip, Lang), case lists:member(Server, ?MYHOSTS) of - true -> - Lang = case xml:get_attr_s(<<"xml:lang">>, Attrs) of - Lang1 when size(Lang1) =< 35 -> - %% As stated in BCP47, 4.4.1: - %% Protocols or specifications that - %% specify limited buffer sizes for - %% language tags MUST allow for - %% language tags of at least 35 characters. - Lang1; - _ -> - %% Do not store long language tag to - %% avoid possible DoS/flood attacks - <<"">> - end, + true when IsBlacklistedIP == false -> change_shaper(StateData, jlib:make_jid(<<"">>, Server, <<"">>)), case xml:get_attr_s(<<"version">>, Attrs) of <<"1.0">> -> @@ -524,6 +516,15 @@ wait_for_stream({xmlstreamstart, _Name, Attrs}, StateData) -> lang = Lang}) end end; + true -> + IP = StateData#state.ip, + {true, LogReason, ReasonT} = IsBlacklistedIP, + ?INFO_MSG("Connection attempt from blacklisted IP ~s: ~s", + [jlib:ip_to_list(IP), LogReason]), + send_header(StateData, Server, <<"">>, DefaultLang), + send_element(StateData, ?POLICY_VIOLATION_ERR(Lang, ReasonT)), + send_trailer(StateData), + {stop, normal, StateData}; _ -> send_header(StateData, ?MYNAME, <<"">>, DefaultLang), send_element(StateData, ?HOST_UNKNOWN_ERR), @@ -2492,9 +2493,9 @@ fsm_reply(Reply, StateName, StateData) -> {reply, Reply, StateName, StateData, ?C2S_OPEN_TIMEOUT}. %% Used by c2s blacklist plugins -is_ip_blacklisted(undefined) -> false; -is_ip_blacklisted({IP, _Port}) -> - ejabberd_hooks:run_fold(check_bl_c2s, false, [IP]). +is_ip_blacklisted(undefined, _Lang) -> false; +is_ip_blacklisted({IP, _Port}, Lang) -> + ejabberd_hooks:run_fold(check_bl_c2s, false, [IP, Lang]). %% Check from attributes %% returns invalid-from|NewElement diff --git a/src/mod_fail2ban.erl b/src/mod_fail2ban.erl index f16a1fcea..b246e402c 100644 --- a/src/mod_fail2ban.erl +++ b/src/mod_fail2ban.erl @@ -9,40 +9,153 @@ -module(mod_fail2ban). -behaviour(gen_mod). +-behaviour(gen_server). %% API --export([start/2, stop/1, c2s_auth_result/4, check_bl_c2s/2]). +-export([start_link/2, start/2, stop/1, c2s_auth_result/4, check_bl_c2s/3]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, + terminate/2, code_change/3]). + +-include_lib("stdlib/include/ms_transform.hrl"). +-include("ejabberd.hrl"). +-include("logger.hrl"). + +-define(C2S_AUTH_BAN_LIFETIME, 3600). %% 1 hour +-define(C2S_MAX_AUTH_FAILURES, 20). +-define(CLEAN_INTERVAL, timer:minutes(10)). + +-record(state, {host = <<"">> :: binary()}). %%%=================================================================== %%% API %%%=================================================================== -start(Host, _Opts) -> +start_link(Host, Opts) -> + Proc = gen_mod:get_module_proc(Host, ?MODULE), + gen_server:start_link({local, Proc}, ?MODULE, [Host, Opts], []). + +c2s_auth_result(false, _User, LServer, {Addr, _Port}) -> + BanLifetime = gen_mod:get_module_opt( + LServer, ?MODULE, c2s_auth_ban_lifetime, + fun(T) when is_integer(T), T > 0 -> T end, + ?C2S_AUTH_BAN_LIFETIME), + MaxFailures = gen_mod:get_module_opt( + LServer, ?MODULE, c2s_max_auth_failures, + fun(I) when is_integer(I), I > 0 -> I end, + ?C2S_MAX_AUTH_FAILURES), + UnbanTS = unban_timestamp(BanLifetime), + case ets:lookup(failed_auth, Addr) of + [{Addr, N, _, _}] -> + ets:insert(failed_auth, {Addr, N+1, UnbanTS, MaxFailures}); + [] -> + ets:insert(failed_auth, {Addr, 1, UnbanTS, MaxFailures}) + end; +c2s_auth_result(true, _User, _Server, _AddrPort) -> + ok. + +check_bl_c2s(_Acc, Addr, Lang) -> + case ets:lookup(failed_auth, Addr) of + [{Addr, N, TS, MaxFailures}] when N >= MaxFailures -> + case TS > now() of + true -> + IP = jlib:ip_to_list(Addr), + UnbanDate = format_date( + calendar:now_to_universal_time(TS)), + LogReason = io_lib:fwrite( + "Too many (~p) failed authentications " + "from this IP address (~s). The address " + "will be unblocked at ~s UTC", + [N, IP, UnbanDate]), + ReasonT = io_lib:fwrite( + translate:translate( + Lang, + <<"Too many (~p) failed authentications " + "from this IP address (~s). The address " + "will be unblocked at ~s UTC">>), + [N, IP, UnbanDate]), + {stop, {true, LogReason, ReasonT}}; + false -> + ets:delete(failed_auth, Addr), + false + end; + _ -> + false + end. + +%%==================================================================== +%% gen_mod callbacks +%%==================================================================== +start(Host, Opts) -> catch ets:new(failed_auth, [named_table, public]), - ejabberd_hooks:add(c2s_auth_result, Host, ?MODULE, c2s_auth_result, 100), - ejabberd_hooks:add(check_bl_c2s, ?MODULE, check_bl_c2s, 100). + Proc = gen_mod:get_module_proc(Host, ?MODULE), + ChildSpec = {Proc, {?MODULE, start_link, [Host, Opts]}, + transient, 1000, worker, [?MODULE]}, + supervisor:start_child(ejabberd_sup, ChildSpec). stop(Host) -> + Proc = gen_mod:get_module_proc(Host, ?MODULE), + supervisor:terminate_child(ejabberd_sup, Proc), + supervisor:delete_child(ejabberd_sup, Proc). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== +init([Host, _Opts]) -> + ejabberd_hooks:add(c2s_auth_result, Host, ?MODULE, c2s_auth_result, 100), + ejabberd_hooks:add(check_bl_c2s, ?MODULE, check_bl_c2s, 100), + erlang:send_after(?CLEAN_INTERVAL, self(), clean), + {ok, #state{host = Host}}. + +handle_call(_Request, _From, State) -> + Reply = ok, + {reply, Reply, State}. + +handle_cast(_Msg, State) -> + ?ERROR_MSG("got unexpected cast = ~p", [_Msg]), + {noreply, State}. + +handle_info(clean, State) -> + ?DEBUG("cleaning ~p ETS table", [failed_auth]), + Now = now(), + ets:select_delete( + failed_auth, + ets:fun2ms(fun({_, _, UnbanTS, _}) -> UnbanTS =< Now end)), + erlang:send_after(?CLEAN_INTERVAL, self(), clean), + {noreply, State}; +handle_info(_Info, State) -> + ?ERROR_MSG("got unexpected info = ~p", [_Info]), + {noreply, State}. + +terminate(_Reason, #state{host = Host}) -> ejabberd_hooks:delete(c2s_auth_result, Host, ?MODULE, c2s_auth_result, 100), - ejabberd_hooks:delete(check_bl_c2s, ?MODULE, check_bl_c2s, 100). + case is_loaded_at_other_hosts(Host) of + true -> + ok; + false -> + ejabberd_hooks:delete(check_bl_c2s, ?MODULE, check_bl_c2s, 100), + ets:delete(failed_auth) + end. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. %%%=================================================================== %%% Internal functions %%%=================================================================== -c2s_auth_result(false, _User, _Server, {Addr, _Port}) -> - case ets:lookup(failed_auth, Addr) of - [] -> - ets:insert(failed_auth, {Addr, 1}); - _ -> - ets:update_counter(failed_auth, Addr, 1) - end, - timer:sleep(3); -c2s_auth_result(true, _User, _Server, _AddrPort) -> - ok. +unban_timestamp(BanLifetime) -> + {MegaSecs, MSecs, USecs} = now(), + UnbanSecs = MegaSecs * 1000000 + MSecs + BanLifetime, + {UnbanSecs div 1000000, UnbanSecs rem 1000000, USecs}. -check_bl_c2s(_Acc, Addr) -> - case ets:lookup(failed_auth, Addr) of - [{Addr, N}] when N >= 100 -> - {stop, true}; - _ -> - false - end. +is_loaded_at_other_hosts(Host) -> + lists:any( + fun(VHost) when VHost == Host -> + false; + (VHost) -> + gen_mod:is_loaded(VHost, ?MODULE) + end, ?MYHOSTS). + +format_date({{Year, Month, Day}, {Hour, Minute, Second}}) -> + io_lib:format("~2..0w:~2..0w:~2..0w ~2..0w.~2..0w.~4..0w", + [Hour, Minute, Second, Day, Month, Year]). diff --git a/src/mod_ip_blacklist.erl b/src/mod_ip_blacklist.erl index f0feb6551..1dd641ce5 100644 --- a/src/mod_ip_blacklist.erl +++ b/src/mod_ip_blacklist.erl @@ -37,7 +37,7 @@ -export([update_bl_c2s/0]). %% Hooks: --export([is_ip_in_c2s_blacklist/2]). +-export([is_ip_in_c2s_blacklist/3]). -include("ejabberd.hrl"). -include("logger.hrl"). @@ -107,14 +107,23 @@ update_bl_c2s() -> %% Return: false: IP not blacklisted %% true: IP is blacklisted %% IPV4 IP tuple: -is_ip_in_c2s_blacklist(_Val, IP) when is_tuple(IP) -> +is_ip_in_c2s_blacklist(_Val, IP, Lang) when is_tuple(IP) -> BinaryIP = jlib:ip_to_list(IP), case ets:lookup(bl_c2s, BinaryIP) of [] -> %% Not in blacklist false; - [_] -> {stop, true} + [_] -> + LogReason = io_lib:fwrite( + "This IP address is blacklisted in ~s", + [?BLC2S]), + ReasonT = io_lib:fwrite( + translate:translate( + Lang, + <<"This IP address is blacklisted in ~s">>), + [?BLC2S]), + {stop, {true, LogReason, ReasonT}} end; -is_ip_in_c2s_blacklist(_Val, _IP) -> false. +is_ip_in_c2s_blacklist(_Val, _IP, _Lang) -> false. %% TODO: %% - For now, we do not kick user already logged on a given IP after