Skip to content

Commit f6b844c

Browse files
committed
ssh: extend hardening guide
Add sections on SFTP enablement, network-level security and advanced authentication: - SFTP subsystem enablement with root option and rights model reference - Non-root VM recommendation in introduction - IP binding restrictions (daemon/3 vs daemon/2 default behavior) - MFA limitations and VPN alternative recommendation - Public key validation via key_cb with pk_check_user - Account lockout using pwdfun with cross-connection ETS tracking
1 parent 010d13e commit f6b844c

10 files changed

Lines changed: 258 additions & 63 deletions
-39.3 KB
Binary file not shown.

lib/ssh/doc/assets/ssh_timeouts.jpg.license

Lines changed: 0 additions & 21 deletions
This file was deleted.

lib/ssh/doc/guides/hardening.md

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ limitations under the License.
2626
The Erlang/OTP SSH application is intended to be used in other applications as a
2727
library.
2828

29+
Ensure the Erlang VM runs as a non-root OS user. All SSH services (shell,
30+
exec, SFTP) inherit the OS-level rights of the VM process. See
31+
[Terminology](terminology.md) for details on the rights model.
32+
2933
Different applications using this library may have very different requirements.
3034
One application could be running on a high performance server, while another is
3135
running on a small device with very limited cpu capacity. For example, the first
@@ -53,7 +57,7 @@ increase the resilence. The options to use are:
5357

5458
- **[max_sessions](`m:ssh#hardening_daemon_options-max_sessions`)** - The
5559
maximum number of simultaneous sessions that are accepted at any time for this
56-
daemon. This includes sessions that are being authorized. The default is that
60+
daemon. This includes sessions that are being authenticated. The default is that
5761
an unlimited number of simultaneous sessions are allowed. It is a good
5862
candidate to set if the capacity of the server is low or a capacity margin is
5963
needed.
@@ -96,9 +100,19 @@ increase the resilence. The options to use are:
96100
receiving any message back. Alive messages are typically used to detect that a connection
97101
became unresponsive.
98102

99-
A figure clarifies when a timeout is started and when it triggers:
103+
The following table clarifies when a timeout is started and when it triggers:
100104

101-
![SSH server timeouts](assets/ssh_timeouts.jpg "SSH server timeouts")
105+
| # | Event | Timeout started | Timeout ended |
106+
|---|-------|-----------------|---------------|
107+
| 1 | TCP connected | `hello_timeout`, `negotiation_timeout` | |
108+
| 2 | First SSH message received | | `hello_timeout` |
109+
| 3 | Key Exchange finished | | |
110+
| 4 | Authenticated | `max_initial_idle_time` | `negotiation_timeout` |
111+
| 5 | Channel 1 opened | | `max_initial_idle_time` |
112+
| 6 | Channel *n* opened | | |
113+
| 7 | Channel *x_1* closed | | |
114+
| 8 | Channel *x_n* closed (all channels closed) | `idle_time` | |
115+
| 9 | Connection closed | | `idle_time` |
102116

103117
### Resilience to compression-based attacks
104118

@@ -220,20 +234,18 @@ connect to and not an evil one impersonating the expected one using its own
220234
valid key-pair? There are two alternatives available with the default key
221235
handling plugin `m:ssh_file`. The alternatives are:
222236

223-
- **Pre-store the host key** - \* For the default handler ssh_file, store the
237+
- **Pre-store the host key** - For the default handler ssh_file, store the
224238
valid host keys in the file [`known_hosts`](`m:ssh_file#FILE-known_hosts`) and
225239
set the option
226240
[silently_accept_hosts](`m:ssh#hardening_client_options-silently_accept_hosts`)
227-
to `false`.
228-
229-
- or, write a specialized key handler using the
230-
[SSH client key API](`m:ssh_client_key_api`) that accesses the pre-shared
231-
key in some other way.
241+
to `false`. Alternatively, write a specialized key handler using the
242+
[SSH client key API](`m:ssh_client_key_api`) that accesses the pre-shared
243+
key in some other way.
232244

233-
- **Pre-store the "fingerprint" (checksum) of the host key** - \*
245+
- **Pre-store the "fingerprint" (checksum) of the host key** - Use
234246
[silently_accept_hosts](`m:ssh#hardening_client_options-silently_accept_hosts`)
235-
- [`accept_callback()`](`t:ssh:accept_callback/0`)
236-
- [`{HashAlgoSpec, accept_callback()}`](`t:ssh:accept_hosts/0`)
247+
with a callback: [`accept_callback()`](`t:ssh:accept_callback/0`)
248+
or [`{HashAlgoSpec, accept_callback()}`](`t:ssh:accept_hosts/0`).
237249

238250
## Verifying the remote client in a daemon (server)
239251

@@ -308,6 +320,24 @@ The options [exec](`t:ssh:exec_daemon_option/0`) and
308320
The same options could also install handlers for
309321
the string(s) passed from the client to the server.
310322

323+
### Enabling the SFTP subsystem
324+
325+
The SFTP subsystem is not enabled by default. When enabled, SFTP provides
326+
access to the file system with the rights of the OS process running the
327+
Erlang emulator, regardless of the authenticated SSH user. See the
328+
[Terminology](terminology.md) section for details.
329+
330+
The [subsystems](`t:ssh:subsystem_daemon_option/0`) option controls which
331+
subsystems are available. To enable SFTP:
332+
333+
```erlang
334+
ssh:daemon({192, 168, 1, 10}, Port,
335+
[{subsystems, [ssh_sftpd:subsystem_spec([])]} | Options]).
336+
```
337+
338+
Use the `root` option to restrict SFTP users to a specific directory
339+
tree (see [Root Directory Isolation](#root-directory-isolation) below).
340+
311341
### The id string
312342

313343
One way to reduce the risk of intrusion is to not convey which software and
@@ -332,7 +362,7 @@ This brand and version may be changed with the option
332362
option:
333363

334364
```erlang
335-
ssh:daemon(1234, [{id_string,"hi there"}, ... ]).
365+
ssh:daemon({192, 168, 1, 10}, 1234, [{id_string,"hi there"}, ... ]).
336366
```
337367

338368
and the daemon will present itself as:
@@ -383,3 +413,82 @@ using OS-level mechanisms (PAM chroot, containers, file permissions).
383413
**Defense-in-depth:** For high-security deployments, combine the `root` option
384414
with OS-level isolation mechanisms such as chroot jails, containers, or
385415
mandatory access control (SELinux, AppArmor).
416+
417+
## Network-Level Security
418+
419+
### IP Binding Restrictions
420+
421+
`ssh:daemon/1` and `ssh:daemon/2` bind to **all network interfaces** by
422+
default. For hardened deployments, use `ssh:daemon/3` with an explicit
423+
IP address or `loopback`:
424+
425+
```erlang
426+
ssh:daemon({192, 168, 1, 10}, 2222, Options). % Specific interface
427+
ssh:daemon(loopback, 2222, Options). % Localhost only
428+
```
429+
430+
**Note**: In the examples above, `HostAddress` (1st argument) takes precedence
431+
over a potentially provided `{ip, Address}` in `Options` (3rd argument).
432+
433+
## Advanced Authentication
434+
435+
The following techniques provide enhanced authentication controls using
436+
custom callbacks. These require implementation specific to your environment.
437+
438+
### Public Key Validation
439+
440+
Use a custom [`key_cb`](`t:ssh:key_cb_common_option/0`) module implementing
441+
the `m:ssh_server_key_api` behaviour. The `is_auth_key` callback can enforce
442+
client key strength requirements (e.g. reject RSA keys shorter than 2048 bits)
443+
and log key usage for auditing. Enable
444+
[`pk_check_user`](`m:ssh#option-pk_check_user`) to verify that the username
445+
is known before accepting public key authentication.
446+
447+
```erlang
448+
ssh:daemon({192, 168, 1, 10}, Port, [
449+
{key_cb, {my_key_handler, []}},
450+
{pk_check_user, true}
451+
]).
452+
```
453+
454+
### Account Lockout Policies
455+
456+
Use [`pwdfun`](`t:ssh:pwdfun_4/0`) with an ETS table to track failed
457+
attempts across connections and lock accounts after repeated failures.
458+
Return `disconnect` when the account is locked — this immediately
459+
terminates the connection with `SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE`:
460+
461+
> #### Note {: .info }
462+
>
463+
> The lockout example below is conceptual. With
464+
> [`parallel_login`](`m:ssh#hardening_daemon_options-parallel_login`) enabled,
465+
> race conditions may reduce lockout accuracy.
466+
467+
```erlang
468+
lockout_pwdfun(User, Password, _PeerAddr, State) ->
469+
case ets:lookup(ssh_lockouts, {locked, User}) of
470+
[_] ->
471+
disconnect;
472+
[] ->
473+
case validate_password(User, Password) of
474+
true ->
475+
ets:delete(ssh_lockouts, {attempts, User}),
476+
{true, State};
477+
false ->
478+
N = ets:update_counter(ssh_lockouts,
479+
{attempts, User}, 1,
480+
{{attempts, User}, 0}),
481+
case N >= ?LOCKOUT_THRESHOLD of
482+
true ->
483+
ets:insert(ssh_lockouts,
484+
{{locked, User}, true}),
485+
ets:delete(ssh_lockouts,
486+
{attempts, User});
487+
false ->
488+
ok
489+
end,
490+
{false, State}
491+
end
492+
end.
493+
```
494+

lib/ssh/doc/guides/introduction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Channels come in the following three flavors:
119119
The Erlang `ssh` daemon can be configured to run any Erlang-implemented SSH
120120
subsystem.
121121
- _Shell_ \- Interactive shell. By default the Erlang daemon does not expose the Erlang
122-
shell. It can be enabled with option `{shell, {shell, start, []}}`
122+
shell. It can be enabled with option `{shell, {shell, start, []}}`.
123123
The shell can be customized by providing your own read-eval-print loop.
124124
You can also provide your own Command-Line Interface (CLI) implementation, but
125125
that is much more work.

lib/ssh/doc/guides/using_ssh.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ To collect the channel messages in a program, use `receive...end` instead of
165165
```erlang
166166
5> receive
167167
5> {ssh_cm, ConnectionRef, {data, ChannelId, Type, Result}} when Type == 0 ->
168-
5> {ok,Result}
168+
5> {ok,Result};
169169
5> {ssh_cm, ConnectionRef, {data, ChannelId, Type, Result}} when Type == 1 ->
170170
5> {error,Result}
171171
5> end.
@@ -359,7 +359,7 @@ The error return in the Erlang client (The text as data type 1 and exit_status
359359
{ok,<0.92.0>}
360360
3> {ok, ChannelId} = ssh_connection:session_channel(ConnectionRef, infinity).
361361
{ok,0}
362-
4> success = ssh_connection:exec(ConnectionRef, ChannelId, "1+ 2.").
362+
4> success = ssh_connection:exec(ConnectionRef, ChannelId, "1+ 2.", infinity).
363363
success
364364
5> flush().
365365
Shell got {ssh_cm,<0.106.0>,{data,0,1,<<"**Error** {bad_input,\"1+ 2.\"}">>}}
@@ -565,7 +565,7 @@ described in Section
565565
1> ssh:start().
566566
ok
567567
2> ssh:daemon(8989, [{system_dir, "/tmp/ssh_daemon"},
568-
{user_dir, "/tmp/otptest_user/.ssh"}
568+
{user_dir, "/tmp/otptest_user/.ssh"},
569569
{subsystems, [{"echo_n", {ssh_echo_server, [10]}}]}]).
570570
{ok,<0.54.0>}
571571
3>

lib/ssh/doc/src/ssh_timeouts.odp

-13.5 KB
Binary file not shown.

lib/ssh/doc/src/ssh_timeouts.odp.license

Lines changed: 0 additions & 21 deletions
This file was deleted.

lib/ssh/src/ssh.hrl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1044,7 +1044,7 @@ supporting ext-info.
10441044
equiv => pwdfun_4/0}).
10451045
-type pwdfun_2() :: fun((User::string(), Password::string()|pubkey) -> boolean()) .
10461046
-doc """
1047-
- **`auth_method_kb_interactive_data`** - Sets the text strings that the daemon
1047+
- **`auth_method_kb_interactive_data`{: #option-auth_method_kb_interactive_data }** - Sets the text strings that the daemon
10481048
sends to the client for presentation to the user when using
10491049
`keyboard-interactive` authentication.
10501050

lib/ssh/test/ssh_connection_SUITE.erl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2117,14 +2117,17 @@ replace_options_enable_services(Config) when is_list(Config) ->
21172117
?CT_LOG("Checking SFTP is unavailable before replace", []),
21182118
{error, _} = ssh_sftp:start_channel(C1),
21192119
?CT_LOG("All services confirmed disabled", []),
2120-
ssh:close(C1),
21212120

21222121
%% Enable shell, exec and SFTP
2122+
%% Keep C1 open during replace — its connection_sup acts as a
2123+
%% keepalive preventing auto_shutdown of ssh_system_sup while
2124+
%% the acceptor is being restarted.
21232125
?CT_LOG("Replacing options: enabling shell, exec and SFTP", []),
21242126
{ok, Pid} = ssh:daemon_replace_options(Pid,
21252127
[{shell, {shell, start, []}},
21262128
{exec, erlang_eval},
21272129
{subsystems, [ssh_sftpd:subsystem_spec([])]}]),
2130+
ssh:close(C1),
21282131

21292132
%% Verify all services work after replace
21302133
C2 = ssh_test_lib:connect(Host, Port, ConnOpts),

0 commit comments

Comments
 (0)