This is a security hardened configuration of OpenSSH setup exclusively as an SFTP server. Multiple chrooted user accounts are created, according to your environment file, allowing login with public key auth only, and this gives protected external file transfer to/from any of your Docker volumes.
OpenSSH has been hardened in the following ways:
- sshd_config has been configured to:
- Use only v2 protocol with ed25519 or RSA key types.
- Require pubkey authentication exclusively (
PubkeyAuthentication yes
andPasswordAuthentication no
). - Only serve SFTP with the
internal-sftp
server. - Disable the shell, port forwading, and X11 forwarding.
- Per-user account chroot (
ChrootDirectory
). Each user can only see their own files.
- In order to use the
ChrootDirectory
config directive,sshd
must run as root to access chroot(2) (even if you give it theCAP_SYS_CHROOT
capability via setcap, an unpatched sshd will still refuse to allow chroot(2) unless UID==0 explicitly; so a non-root user would be unable to runsshd
with theChrootDirectory
directive). To limit the permissions of the root user, the Docker container drops all of the unnecessary Linux system capabilities, except for the following list that are still required (tested by process of elimination):
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SYS_CHROOT
- AUDIT_WRITE
- SETGID
- SETUID
- FOWNER
- All config files and keys are made immutable by a secondary config
container, by temporarily giving it the
LINUX_IMMUTABLE
capability. This capability is dropped by the mainsftp
container, such that these files are made completely unmodifiable, even by the root user. Every time you runmake install
the keys and config files are temporarily made mutable, then reconfigured according to your environment, and then relocked again before startingsftp
.
Each instance of sftp
may configure its own custom list of external
Docker volumes to mount (SFTP_VOLUMES
). This utilizes a
docker-compose override
file. The override is
created automatically from the override template:
docker-compose.instance.yaml. The
generated override file
(docker-compose.override_${DOCKER_CONTEXT}_default.yaml
) is created
automatically whenever you run make config
based upon the
SFTP_VOLUMES
environment variable (the override file should not be
hand edited). When you run make install
, the base configuration
docker-compose.yaml is merged with the
generated override file, to compose the full configuration.
If SFTP_VOLUMES
is not used (ie. blank) then the sftp
instance
will not mount any external volumes, and will only use the internal
volumes specified by the base configuration
(docker-compose.yaml): sftp_config
and
sftp_data
(or the custom-instance volumes: sftp_${INSTANCE}_config
and sftp_${INSTANCE}_data
).
The volume mount points are :
-
sftp_ssh-config
mounted to/etc/ssh
as root, containing all the configuration, private keys, and authorized public keys. -
sftp_ssh_data
mounted to/data
. Under this directory, each user is given their own unique chroot that is owned by root, for example:/data/bob-chroot
(owned by root)/data/alice-chroot
(owned by root)
-
/data/${USER}-chroot
directory must be owned by root in order for the sshd_configChrootDirectory
directive to work. Each user is given permission to write to a subdirectory with their own name, for example:/data/bob-chroot/bob
(owned by bob, backed by the defaultsftp_data
volume)/data/alice-chroot/alice
(owned by alice, backed by the defaultsftp_data
volume)
-
If
SFTP_VOLUMES
is specified, extra external volumes are mounted in addition, for example ifSFTP_VOLUMES=some_volume:bob:stuff,other_volume:alice:misc
, then the following extra directories are created/mounted:/data/bob-chroot/bob/stuff
/data/alice-chroot/alice/misc
-
When each user logs in, they are placed in a chroot with only their own files visible:
- Bob only sees:
/bob
and/bob/stuff
. - Alice only sees:
/alice
and/alice/misc
.
- Bob only sees:
Run:
make config
Answer the questions for these variables:
SFTP_PORT
This is the publicly exposed TCP port of the SFTP server. (Note: this port is directly mapped on the host, it does not flow through Traefik.)
SFTP_PORT=2223
SFTP_USERS
This is a comma separated list of user:UID pairs to create SFTP accounts. Match the UID to the same UID that your data (or service container) uses (see example below). If you only use the default sftp volume (without any other consumers of the volume), then the UIDs may be arbitrarily chosen but should be unique.
## For example, to create two accounts, ryan (UID=54321) and gary (UID=1001):
SFTP_USERS=ryan:54321,gary:1001
SFTP_VOLUMES
This is a comma separated list of volume:user:mount 3-tuples, to configure the external volumes and user mountpoints. These volumes should already exist before startingsftp
. If you only use the default sftp volume, thenSFTP_VOLUMES
may be left blank. The order of the items of the 3-tuple are: 1)volume
- the name of the Docker volume, 2)user
- the SFTP user account name, 3)mount
the name for the mountpoint in the container (this can be arbitrary, but should be recognizable to the SFTP user.)
## For example, to mount two volumes: thttpd_files and music_stuff
## Volume `thttpd_files` will mount to /data/ryan-chroot/ryan/web
## Volume `music_stuff` will mount to /data/gary-chroot/gary/music
SFTP_VOLUMES=thttpd_files:ryan:web,music_stuff:gary:music
This example will create the following services:
- thttpd - A static HTTP websever served with HTML files stored in a volume.
sftp
setup to share the thttp volume, and create an SFTP user account to manage uploading new files.
First, it is assumed that you have already followed the main d.rymcg.tech README and have setup Traefik on your Docker server.
Start by deploying the thttpd webserver:
cd ~/git/vendor/enigmacurry/d.rymcg.tech/thttpd
make config
make install
Respond to the question for THTTPD_TRAEFIK_HOST
, and set the domain
name for the webserver (eg. www.example.com
). Notice in the .env
file it sets the following without asking:
## These are the UID and GID of the thttpd webserver and all its files:
THTTPD_UID=54321
THTTPD_GID=54321
Once its deployed, you should be able to see the Hello World!
message by visiting the URL for the server (eg. www.example.com
).
cd ~/git/vendor/enigmacurry/d.rymcg.tech/sftp
make config
Answer the question for SFTP_USERS
, you can choose an arbitrary
username, but be mindful to choose the same UID that thttpd
uses:
ryan:54321
Answer the question for SFTP_VOLUMES
, the default volume for thttpd
is thttpd_files
, enter the same username as you chose in
SFTP_USERS
, and any mountpoiint name you wish (all joined with :
):
thttpd_files:ryan:web
This will have configured two files:
.env_${DOCKER_CONTEXT}_default
containing the environment variables.docker-compose.overrid_${DOCKER_CONTEXT}_${INSTANCE:-default}.yaml
(eg.docker-compose.override_docker-vm_default.yaml
for the default instance, ordocker-compose.override_docker-vm_foo.yaml
for thefoo
instance.) This should contain the customizied volume mounts. (this is generated by the ytt template docker-compose.instance.yaml)
Now you can install sftp
:
## Still in the sftp directory:
make install
The override configuration is merged with the base template docker-compose.yaml automatically.
Check that the service has started properly:
$ make logs
...
sftp-sftp-1 | Server listening on 0.0.0.0 port 2000.
sftp-sftp-1 | Server listening on :: port 2000.
^C
$ make status
NAME ENV ID IMAGE STATE PORTS
sftp-sftp-1 .env_ssh.t.rymcg.tech ad388ba4f6 sftp-sftp running {"2000/tcp":[{"HostIp":"0.0.0.0","HostPort":"2223"},{"HostIp":"::","HostPort":"2223"}]}
You won't be able to login to the SFTP server until you add SSH public
keys for the accounts. This is handled as a separate manual process,
to be performed after sftp
is running.
You can easily add your own local workstation public keys to any
existing SFTP user account (SFTP_USERS
):
## This copies the key from your running ssh-agent
make ssh-copy-id
This will prompt you to enter an SFTP user account, and then your
local workstation keys are queried from your running SSH agent
(ssh-add -L
) and copied to the SFTP server authorized_keys file.
You can also bypass the wizard by inputting the username on the command line:
## This copies the key from your running ssh-agent
make ssh-copy-id user=ryan
To import keys other than your local agent's, by copy and paste:
## This will prompt you to paste the a public key directly:
make ssh-authorize-key user=ryan
Once the public keys are installed, you can test logging into the
server, using the correct port (SFTP_PORT
) and username
(SFTP_USERS
) (you can use any domain name that resolves to your
public server IP address):
sftp -P 2223 [email protected]
Install sshfs
from your package manager.
Run
make sshfs
This will temporarily mount the remote directories on your workstation
under ~/mnt/sftp.{ROOT_DOMAIN}
.
I started down the path of wanting to run sshd as an unprivileged
non-root user. I found a good set of notes about that
here,
and heres a log of what I tried. For my purposes, I wanted to run
sshd
rootless on a normal installation of Docker. Docker Engine, in
its default configuration, runs as root, and by default all
containers run as root as well. Docker does have a User Namespace
mode and a Rootless
mode and there are
also implementations like Podman that can be run rootless, (any of
which let you map unused UID ranges to your containers, allowing the
appearance of root in the container, but it is to be mapped to some
non-root user UID on the host) but none of these options are under
consideration for this situation. Regretably, you cannot run a
container (traefik) in host networking mode, without also having real
root access, so default Docker is what we're stuck with.
My requirements, including nice-to-haves:
- Must run on a default configured Docker server (Docker Engine running as root).
- Must drop as many privileges as possible to limit attack surface.
- To prevent abuse, the user must not be able to modify any of the
SSH host keys,
/etc/ssh/sshd_config
, nor their ownauthorized_keys
file. An admin is required to do these things. Bonus points if the user cannot even see these things. - Must allow at least one user to login with public key authentication only, and then transfer only their own files in/out of a preconfigured directory/volume permissioned for the user.
- Nice to have: support multiple user logins and separate permissioned directories for each.
- Nice to have: users should not see any files from any other users (chroot).
- Nice to have:
sshd
should not run as root if it can be avoided.
The benefits of running sshd as non-root include:
- If there is some vulnerability found in Linux, or Docker, to escape the container environment, and gain access to the host operating system, then an attacker will only inherit the user privileges that the container user (UID) was running as. If the container user is root (UID 0), then an attacker would gain the real host root (UID 0) access as well. If the container user is non-root (eg. UID 1000), then an attacker would only gain access to the same non-root UID (UID 1000) on the host (which may not even exist). This greatly limits any potential vulnerability.
- Consider this: every application you install on Android (which runs on Linux) receives a unique user account, specifically to sandbox each app run based on the UID.
The drawbacks of running sshd as non-root include:
- Without root access, the only user who can login through
sshd
is the same user thatsshd
runs as. - You cannot use the
ChrootDirective
as non-root (UID!=0). Although Linux has a capability to give non-root users access toCAP_SYS_CHROOT
, and you can give the sshd binary access to this privilege bysetcap
, apparently sshd will not use the ability unless the UID==0. When you attempt to run this as any other UID, you will get the error "server lacks privileges to chroot to ChrootDirectory" in the log, which fails due to the uidswap check here which explictly requires an effective UID of 0. This makes some sense considering that OpenSSH is not a Linux-native application, but must support a wider range of host operating systems and maintain secure defaults. - If sshd must run as the same user that is allowed access, and no
chroot is allowed, then that would mean that the user has access to
read or modify the private SSH host key (eg.
/etc/ssh/keys/ssh_host_rsa_key
) and also has access to modify the/etc/ssh/sshd_config
file. Assuming you only deploy the service for one user, and you trust them not to abuse this, this is not so bad, but ideally the user should not be able to destroy their own account nor change keys.
The benefits of config and key file immutablity include:
- The root user, or any user granted the
LINUX_IMMUTABLE
capability, can make files immutable by runningchattr +i FILE
. This prevents any user from modifying or deleting a file, regardless of the permissions and/or owner. Obviously this limitation does not affect the user withLINUX_IMMUTABLE
capability, because this user can simply make the file mutable again by runningchattr -i FILE
. - To make it so that a process run as root (or another privileged
user) cannot use
chattr +i
norchattr -i
, you must drop theLINUX_IMMUTABLE
capability before running the intended task. Once a capability has been dropped, it cannot be re-acquired within the same process (nor its children; assumingsecurity_opt:['no-new-privileges:true']
), thus preventing even root from changing immutable files.
The drawbacks of config and key file immutablity include:
- If the files are immutable, you can't change them, even if you want
to. So in order to modify them, you need a secondary out-of-band
method of unlocking and relocking the files, from a process that
still retains the
LINUX_IMMUTABLE
capability.
So heres where I ended up:
- Run
sshd
as root (UID=0) since its required to useChrootDirectory
. - Drop all capabilites except for the ones necessary to run chroot(2)
and other stuff determined by process of elmination:
- I tested this by dropping
ALL
privileges, and added back every single one explicitly, then tested ifsshd
works (it definitely should with every capability!) then I tried removing each capability and retesting ifsshd
still works. The final list ofcap_add
directives is the minimal set required:
- I tested this by dropping
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SYS_CHROOT
- AUDIT_WRITE
- SETGID
- SETUID
- FOWNER
- Configure a separate temporary
container
to perform administrative tasks, and to unlock and relock the file
immutability. This process is
granted
the
LINUX_IMMUTABLE
capability in order to perform its tasks.