Skip to content

ssh: Add bannerfun to the server role #9149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/ssh/src/ssh.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -1143,11 +1143,16 @@ in the User's Guide chapter.

- **`failfun`** - Provides a fun to implement your own logging when a user fails
to authenticate.

- **`bannerfun`** - Provides a fun to implement the construction of a banner
text that is sent at the beginning of the user authentication. The banner will
not be sent if the function does not return a binary.
""".
-doc(#{title => <<"Daemon Options">>}).
-type callbacks_daemon_options() ::
{failfun, fun((User::string(), Peer::{inet:ip_address(), inet:port_number()}, Reason::term()) -> _)}
| {connectfun, fun((User::string(), Peer::{inet:ip_address(), inet:port_number()}, Method::string()) ->_)} .
| {connectfun, fun((User::string(), Peer::{inet:ip_address(), inet:port_number()}, Method::string()) ->_)}
| {bannerfun, fun((User::string()) -> binary())}.

-doc(#{title => <<"Other data types">>}).
-type opaque_daemon_options() ::
Expand Down Expand Up @@ -1246,7 +1251,8 @@ in the User's Guide chapter.
userauth_preference,
available_host_keys,
pwdfun_user_state,
authenticated = false
authenticated = false,
userauth_banner_sent = false
}).

-record(alg,
Expand Down
37 changes: 26 additions & 11 deletions lib/ssh/src/ssh_fsm_userauth_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,22 @@ callback_mode() ->
%%---- userauth request to server
handle_event(internal,
Msg = #ssh_msg_userauth_request{service = ServiceName,
method = Method},
method = Method,
user = User},
StateName = {userauth,server},
D0 = #data{ssh_params=Ssh0}) ->

D0) ->
D1 = maybe_send_banner(D0, User),
#data{ssh_params=Ssh0} = D1,
case {ServiceName, Ssh0#ssh.service, Method} of
{"ssh-connection", "ssh-connection", "none"} ->
%% Probably the very first userauth_request but we deny unauthorized login
%% However, we *may* accept unauthorized login if instructed so
case ssh_auth:handle_userauth_request(Msg, Ssh0#ssh.session_id, Ssh0) of
{not_authorized, _, {Reply,Ssh}} ->
D = ssh_connection_handler:send_msg(Reply, D0#data{ssh_params = Ssh}),
D = ssh_connection_handler:send_msg(Reply, D1#data{ssh_params = Ssh}),
{keep_state, D};
{authorized, User, {Reply, Ssh1}} ->
D = connected_state(Reply, Ssh1, User, Method, D0),
D = connected_state(Reply, Ssh1, User, Method, D1),
{next_state, {connected,server}, D,
[set_max_initial_idle_timeout(D),
{change_callback_module,ssh_connection_handler}
Expand All @@ -87,18 +89,18 @@ handle_event(internal,
%% Yepp! we support this method
case ssh_auth:handle_userauth_request(Msg, Ssh0#ssh.session_id, Ssh0) of
{authorized, User, {Reply, Ssh1}} ->
D = connected_state(Reply, Ssh1, User, Method, D0),
D = connected_state(Reply, Ssh1, User, Method, D1),
{next_state, {connected,server}, D,
[set_max_initial_idle_timeout(D),
{change_callback_module,ssh_connection_handler}
]};
{not_authorized, {User, Reason}, {Reply, Ssh}} when Method == "keyboard-interactive" ->
retry_fun(User, Reason, D0),
D = ssh_connection_handler:send_msg(Reply, D0#data{ssh_params = Ssh}),
retry_fun(User, Reason, D1),
D = ssh_connection_handler:send_msg(Reply, D1#data{ssh_params = Ssh}),
{next_state, {userauth_keyboard_interactive,server}, D};
{not_authorized, {User, Reason}, {Reply, Ssh}} ->
retry_fun(User, Reason, D0),
D = ssh_connection_handler:send_msg(Reply, D0#data{ssh_params = Ssh}),
retry_fun(User, Reason, D1),
D = ssh_connection_handler:send_msg(Reply, D1#data{ssh_params = Ssh}),
{keep_state, D}
end;
false ->
Expand All @@ -116,7 +118,7 @@ handle_event(internal,
{Shutdown, D} =
?send_disconnect(?SSH_DISCONNECT_SERVICE_NOT_AVAILABLE,
io_lib:format("Unknown service: ~p",[ServiceName]),
StateName, D0),
StateName, D1),
{stop, Shutdown, D}
end;

Expand Down Expand Up @@ -213,3 +215,16 @@ retry_fun(User, Reason, #data{ssh_params = #ssh{opts = Opts,
ok
end.

maybe_send_banner(D0 = #data{ssh_params = #ssh{userauth_banner_sent = false} = Ssh}, User) ->
BannerFun = ?GET_OPT(bannerfun, Ssh#ssh.opts),
case BannerFun(User) of
BannerText when is_binary(BannerText), byte_size(BannerText) > 0 ->
Banner = #ssh_msg_userauth_banner{message = BannerText,
language = <<>>},
D = D0#data{ssh_params = Ssh#ssh{userauth_banner_sent = true}},
ssh_connection_handler:send_msg(Banner, D);
_ ->
D0
end;
maybe_send_banner(D, _) ->
D.
6 changes: 6 additions & 0 deletions lib/ssh/src/ssh_options.erl
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,12 @@ default(server) ->
class => user_option
},

bannerfun =>
#{default => fun(_) -> <<>> end,
chk => fun(V) -> check_function1(V) end,
class => user_option
},

%%%%% Undocumented
infofun =>
#{default => fun(_,_,_) -> void end,
Expand Down
45 changes: 44 additions & 1 deletion lib/ssh/test/ssh_options_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
auth_none/1,
connectfun_disconnectfun_client/1,
disconnectfun_option_client/1,
disconnectfun_option_server/1,
disconnectfun_option_server/1,
bannerfun_server/1,
id_string_no_opt_client/1,
id_string_no_opt_server/1,
id_string_own_string_client/1,
Expand Down Expand Up @@ -114,6 +115,7 @@ suite() ->

all() ->
[connectfun_disconnectfun_server,
bannerfun_server,
connectfun_disconnectfun_client,
server_password_option,
server_userpassword_option,
Expand Down Expand Up @@ -778,6 +780,47 @@ connectfun_disconnectfun_server(Config) ->
{fail, "No connectfun action"}
end.

%%--------------------------------------------------------------------
bannerfun_server(Config) ->
UserDir = proplists:get_value(user_dir, Config),
SysDir = proplists:get_value(data_dir, Config),

Parent = self(),
Ref = make_ref(),
BannerFun = fun(U) -> Parent ! {banner,Ref,U}, list_to_binary(U) end,

{Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SysDir},
{user_dir, UserDir},
{password, "morot"},
{failfun, fun ssh_test_lib:failfun/2},
{bannerfun, BannerFun}]),
ConnectionRef =
ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true},
{user, "foo"},
{password, "morot"},
{user_dir, UserDir},
{user_interaction, false}]),
receive
{banner,Ref,U} ->
"foo" = U,
%% Make sure no second banner is sent
receive
{banner,Ref,U} ->
ssh:close(ConnectionRef),
ssh:stop_daemon(Pid),
{fail, "More than 1 banner sent"}
after 2000 ->
ssh:close(ConnectionRef),
ssh:stop_daemon(Pid)
end
after 10000 ->
receive
X -> ct:log("received ~p",[X])
after 0 -> ok
end,
{fail, "No bannerfun action"}
end.

%%--------------------------------------------------------------------
connectfun_disconnectfun_client(Config) ->
UserDir = proplists:get_value(user_dir, Config),
Expand Down
114 changes: 113 additions & 1 deletion lib/ssh/test/ssh_protocol_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
bad_service_name_length/2,
bad_service_name_then_correct/1,
bad_very_long_service_name/1,
banner_sent_to_client/1,
banner_not_sent_to_client/1,
client_handles_keyboard_interactive_0_pwds/1,
client_handles_banner_keyboard_interactive/1,
client_info_line/1,
Expand Down Expand Up @@ -145,7 +147,9 @@ groups() ->
bad_service_name_then_correct
]},
{authentication, [], [client_handles_keyboard_interactive_0_pwds,
client_handles_banner_keyboard_interactive
client_handles_banner_keyboard_interactive,
banner_sent_to_client,
banner_not_sent_to_client
]},
{ext_info, [], [no_ext_info_s1,
no_ext_info_s2,
Expand Down Expand Up @@ -761,6 +765,74 @@ client_handles_banner_keyboard_interactive(Config) ->
]}]
).

banner_sent_to_client(Config) ->
BannerFun = fun(U) -> list_to_binary(U) end,
User = "foo",
Pwd = "morot",
UserDir = user_dir(Config),
{Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, system_dir(Config)},
{user_dir, UserDir},
{password, Pwd},
{failfun, fun ssh_test_lib:failfun/2},
{bannerfun, BannerFun}]),

{ok,AfterUserAuthReqState} = connect_and_userauth_request(Host, Port, User, Pwd, UserDir),
{ok,EndState} =
ssh_trpt_test_lib:exec(
[{match, #ssh_msg_userauth_banner{message = BannerFun(User),
language = <<>>}, receive_msg},
{match, #ssh_msg_userauth_success{_='_'}, receive_msg}
], AfterUserAuthReqState),

{ok,_} = trpt_test_lib_send_disconnect(EndState),

ssh:stop_daemon(Pid),
Config.

banner_not_sent_to_client(Config) ->
%% Bad bannerfun
BBF = fun(_U) -> no_banner_is_sent_because_bannerfun_return_is_not_binary end,
User = "foo",
Pwd = "morot",
UserDir = user_dir(Config),
{BBFPid, BBFHost, BBFPort} =
ssh_test_lib:daemon([{system_dir, system_dir(Config)},
{user_dir, UserDir},
{password, Pwd},
{failfun, fun ssh_test_lib:failfun/2},
{bannerfun, BBF}]),

{ok,BBFAfterUserAuthReqState} = connect_and_userauth_request(BBFHost,
BBFPort,
User, Pwd, UserDir),
{ok,BBFEndState} =
ssh_trpt_test_lib:exec(
[{match, #ssh_msg_userauth_success{_='_'}, receive_msg}
], BBFAfterUserAuthReqState),

{ok,_} = trpt_test_lib_send_disconnect(BBFEndState),
ssh:stop_daemon(BBFPid),

%% No bannerfun
{Pid, Host, Port} =
ssh_test_lib:daemon([{system_dir, system_dir(Config)},
{user_dir, UserDir},
{password, Pwd},
{failfun, fun ssh_test_lib:failfun/2}]),

{ok,AfterUserAuthReqState} = connect_and_userauth_request(Host,
Port,
User, Pwd, UserDir),
{ok,EndState} =
ssh_trpt_test_lib:exec(
[{match, #ssh_msg_userauth_success{_='_'}, receive_msg}
], AfterUserAuthReqState),

{ok,_} = trpt_test_lib_send_disconnect(EndState),
ssh:stop_daemon(Pid),

Config.

%%%--------------------------------------------------------------------
client_info_line(Config) ->
%% A client must not send an info-line. If it does, the server should handle
Expand Down Expand Up @@ -1300,3 +1372,43 @@ disconnect(Code) ->
tcp_closed,
{tcp_error,econnaborted}
]}.

%%%----------------------------------------------------------------
connect_and_userauth_request(Host, Port, User, Pwd, UserDir) ->
ssh_trpt_test_lib:exec(
[{set_options, [print_ops, print_messages]},
{connect,Host,Port,
[{preferred_algorithms,[{kex,[?DEFAULT_KEX]},
{cipher,?DEFAULT_CIPHERS}
]},
{silently_accept_hosts, true},
{recv_ext_info, false},
{user_dir, UserDir},
{user_interaction, false}
]},
receive_hello,
{send, hello},
{send, ssh_msg_kexinit},
{match, #ssh_msg_kexinit{_='_'}, receive_msg},
{send, ssh_msg_kexdh_init},
{match,# ssh_msg_kexdh_reply{_='_'}, receive_msg},
{send, #ssh_msg_newkeys{}},
{match, #ssh_msg_newkeys{_='_'}, receive_msg},
{send, #ssh_msg_service_request{name = "ssh-userauth"}},
{match, #ssh_msg_service_accept{name = "ssh-userauth"}, receive_msg},
{send, #ssh_msg_userauth_request{user = User,
service = "ssh-connection",
method = "password",
data = <<?BOOLEAN(?FALSE),
?STRING(unicode:characters_to_binary(Pwd))>>
}}
]).

trpt_test_lib_send_disconnect(State) ->
ssh_trpt_test_lib:exec(
[{send, #ssh_msg_disconnect{code = ?SSH_DISCONNECT_BY_APPLICATION,
description = "End of the fun",
language = ""
}},
close_socket
], State).
6 changes: 5 additions & 1 deletion lib/ssh/test/ssh_to_openssh_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,12 @@ eserver_oclient_renegotiate_helper1(Config) ->
SystemDir = proplists:get_value(data_dir, Config),
PrivDir = proplists:get_value(priv_dir, Config),

%% Having the erlang sending a banner is accepted by openssh
BannerFun = fun(_U) -> <<"Banner to the client">> end,

{Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SystemDir},
{failfun, fun ssh_test_lib:failfun/2}]),
{failfun, fun ssh_test_lib:failfun/2},
{bannerfun, BannerFun}]),
ct:sleep(500),

RenegLimitK = 3,
Expand Down
Loading