From 8d1421d44ac6a225b40383316143cd6f50e77b7f Mon Sep 17 00:00:00 2001 From: Frederik Ring Date: Tue, 20 Oct 2026 18:41:08 +0200 Subject: [PATCH] SSH host's identity is not validated at all --- cmd/backup/config.go | 8 ++++++ cmd/backup/script.go | 17 +++++++----- docs/how-tos/provide-ssh-host-keys.md | 20 ++++++++++++++ docs/recipes/index.md | 2 ++ docs/reference/index.md | 27 ++++++++++++++++++ internal/storage/ssh/ssh.go | 40 ++++++++++++++++++++------- test/ssh/docker-compose.yml | 5 +++- test/ssh/run.sh | 8 +++--- 8 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 docs/how-tos/provide-ssh-host-keys.md diff --git a/cmd/backup/config.go b/cmd/backup/config.go index b23f8864..e68a2c99 100644 --- a/cmd/backup/config.go +++ b/cmd/backup/config.go @@ -74,11 +74,14 @@ type Config struct { WebdavPassword string `split_words:"true"` SSHHostName string `split_words:"true"` SSHPort string `split_words:"true" default:"22"` + SSHHostPublicKey string `split_words:"true"` + SSHKnownHostsFile string `split_words:"true"` SSHUser string `split_words:"true"` SSHPassword string `split_words:"true"` SSHIdentityFile string `split_words:"true" default:"/root/.ssh/id_rsa"` SSHIdentityPassphrase string `split_words:"true"` SSHRemotePath string `split_words:"true"` + SSHInsecureIgnoreHostKey bool `split_words:"true"` ExecLabel string `split_words:"true"` ExecForwardOutput bool `split_words:"true"` LockTimeout time.Duration `split_words:"true" default:"60m"` @@ -364,6 +367,11 @@ func (c *Config) resolve() (reset func() error, warnings []string, err error) { } c.AzureStorageEndpoint = fmt.Sprintf("%s/", strings.TrimSuffix(ep.String(), "/")) } + + if c.SSHHostName != "" && !c.SSHInsecureIgnoreHostKey && (c.SSHHostPublicKey == "" || c.SSHKnownHostsFile != "") { + err = errwrap.Wrap(nil, "SSH hostname given without a public key or known_hosts, refer to the documentation for guidance") + return + } return } diff --git a/cmd/backup/script.go b/cmd/backup/script.go index 078369f9..5a0b0841 100644 --- a/cmd/backup/script.go +++ b/cmd/backup/script.go @@ -186,13 +186,16 @@ func (s *script) init() error { if s.c.SSHHostName != "" { sshConfig := ssh.Config{ - HostName: s.c.SSHHostName, - Port: s.c.SSHPort, - User: s.c.SSHUser, - Password: s.c.SSHPassword, - IdentityFile: s.c.SSHIdentityFile, - IdentityPassphrase: s.c.SSHIdentityPassphrase, - RemotePath: s.c.SSHRemotePath, + HostName: s.c.SSHHostName, + Port: s.c.SSHPort, + User: s.c.SSHUser, + Password: s.c.SSHPassword, + IdentityFile: s.c.SSHIdentityFile, + IdentityPassphrase: s.c.SSHIdentityPassphrase, + RemotePath: s.c.SSHRemotePath, + InsecureIgnoreHostKey: s.c.SSHInsecureIgnoreHostKey, + HostPublicKey: s.c.SSHHostPublicKey, + KnownHostsFile: s.c.SSHKnownHostsFile, } sshBackend, closeSSHConnection, err := ssh.NewStorageBackend(sshConfig, logFunc) diff --git a/docs/how-tos/provide-ssh-host-keys.md b/docs/how-tos/provide-ssh-host-keys.md new file mode 100644 index 00000000..c98795a7 --- /dev/null +++ b/docs/how-tos/provide-ssh-host-keys.md @@ -0,0 +1,20 @@ +--- +title: Provide public host key or known hosts when using the SSH backend +layout: default +parent: How Tos +nav_order: 21 +--- + +# Provide public host key or known hosts when using the SSH backend + +Up to version 2.49.1 the image would not check the SSH host's identity, which is unsafe. +This potentially leaks credentials or backup content to external attackers. + +Starting with version 2.49.2, this is prevented, requiring you to either: + +- provide the host's public key by setting either `SSH_HOST_PUBLIC_KEY` (the key's content) or `SSH_HOST_PUBLIC_KEY_FILE` (the fs location of the key in a file) +- provide the location of a valid known_hosts file by setting `SSH_KNOWN_HOSTS_FILE` +- dangerously set `SSH_INSECURE_IGNORE_HOST_KEY` to ignore host key checks + +{: .warning } +Only set `SSH_INSECURE_IGNORE_HOST_KEY` as a last resort or in entirely airgapped environments. diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 8ac0a2b7..8ca17bea 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -130,6 +130,7 @@ services: image: offen/docker-volume-backup:v2 environment: SSH_HOST_NAME: server.local + SSH_HOST_PUBLIC_KEY_FILE: /config/host_key_ecdsa.pub SSH_PORT: 2222 SSH_USER: user SSH_REMOTE_PATH: /data @@ -137,6 +138,7 @@ services: - data:/backup/my-app-backup:ro - /var/run/docker.sock:/var/run/docker.sock:ro - /path/to/private_key:/root/.ssh/id_rsa + - /path/to/host_key:/config/host_key_ecdsa.pub volumes: data: diff --git a/docs/reference/index.md b/docs/reference/index.md index bbef6e6f..406cd8f1 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -293,6 +293,24 @@ If you need to confirm what the container actually loaded, see [Show loaded conf # --- +# The host's public SSH key +# If you want to mount a file containing the key instead, provide `SSH_HOST_PUBLIC_KEY_FILE` +# Alternatively, provide the location of a known hosts file as shown below. +# If you do not want to provide the key, set `SSH_IGNORE_HOST_KEY` at your own risk + +# SSH_HOST_PUBLIC_KEY="" + +# --- + +# The location of a known_hosts file used when checking the identity of the remote server. +# Alternatively, provide the server's public key as shown above. +# If both a public key and a known_hosts file are set, the key takes precedence. +# If you do not want to provide such a file, set `SSH_IGNORE_HOST_KEY` at your own risk + +# SSH_KNOWN_HOSTS_FILE="" + +# --- + # The port of the remote SSH server # SSH_PORT="22" @@ -334,6 +352,15 @@ If you need to confirm what the container actually loaded, see [Show loaded conf # SSH_IDENTITY_PASSPHRASE="" +# --- + +# Ignore all host key checks. This should be used as a last resort or in +# airgapped environments only. Instead, provide the host's public key as +# described above. Backup integrity and credential confindentiality are at +# risk if using this option. + +# SSH_IGNORE_HOST_KEY="false" + ########### AZURE BLOB STORAGE # The credential's account name when using Azure Blob Storage. This has to be diff --git a/internal/storage/ssh/ssh.go b/internal/storage/ssh/ssh.go index 8c8827a1..e647bf91 100644 --- a/internal/storage/ssh/ssh.go +++ b/internal/storage/ssh/ssh.go @@ -16,6 +16,7 @@ import ( "github.com/offen/docker-volume-backup/internal/storage" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" ) type sshStorage struct { @@ -27,13 +28,16 @@ type sshStorage struct { // Config allows to configure a SSH backend. type Config struct { - HostName string - Port string - User string - Password string - IdentityFile string - IdentityPassphrase string - RemotePath string + HostName string + HostPublicKey string + Port string + User string + Password string + IdentityFile string + IdentityPassphrase string + RemotePath string + KnownHostsFile string + InsecureIgnoreHostKey bool } var noop = func() error { return nil } @@ -69,10 +73,26 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, func( } sshClientConfig := &ssh.ClientConfig{ - User: opts.User, - Auth: authMethods, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + User: opts.User, + Auth: authMethods, } + + if opts.InsecureIgnoreHostKey { + sshClientConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey() + } else if opts.HostPublicKey != "" { + cb, _, _, _, err := ssh.ParseAuthorizedKey([]byte(opts.HostPublicKey)) + if err != nil { + return nil, noop, errwrap.Wrap(err, "error parsing public host key") + } + sshClientConfig.HostKeyCallback = ssh.FixedHostKey(cb) + } else if opts.KnownHostsFile != "" { + cb, err := knownhosts.New(opts.KnownHostsFile) + if err != nil { + return nil, noop, errwrap.Wrap(err, "error parsing knownhosts file") + } + sshClientConfig.HostKeyCallback = cb + } + sshClient, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", opts.HostName, opts.Port), sshClientConfig) if err != nil || sshClient == nil { return nil, noop, errwrap.Wrap(err, "error creating ssh client") diff --git a/test/ssh/docker-compose.yml b/test/ssh/docker-compose.yml index 97a97800..0a2e0920 100644 --- a/test/ssh/docker-compose.yml +++ b/test/ssh/docker-compose.yml @@ -8,6 +8,7 @@ services: volumes: - ${KEY_DIR:-.}/id_rsa.pub:/config/.ssh/authorized_keys - ssh_backup_data:/tmp + - ssh_config_data:/config backup: image: offen/docker-volume-backup:${TEST_VERSION:-canary} @@ -23,6 +24,7 @@ services: BACKUP_PRUNING_LEEWAY: 5s BACKUP_PRUNING_PREFIX: test SSH_HOST_NAME: ssh + SSH_HOST_PUBLIC_KEY_FILE: /ssh_config_data/ssh_host_keys/ssh_host_ecdsa_key.pub SSH_PORT: 2222 SSH_USER: test SSH_REMOTE_PATH: /tmp @@ -30,6 +32,7 @@ services: volumes: - ${KEY_DIR:-.}/id_rsa:/root/.ssh/id_rsa - app_data:/backup/app_data:ro + - ssh_config_data:/ssh_config_data - /var/run/docker.sock:/var/run/docker.sock:ro offen: @@ -41,5 +44,5 @@ services: volumes: ssh_backup_data: - name: ssh_backup_data app_data: + ssh_config_data: diff --git a/test/ssh/run.sh b/test/ssh/run.sh index 6e40a038..5364d8aa 100755 --- a/test/ssh/run.sh +++ b/test/ssh/run.sh @@ -20,7 +20,7 @@ sleep 5 expect_running_containers 3 docker run --rm \ - -v ssh_backup_data:/ssh_data \ + -v ssh_ssh_backup_data:/ssh_data \ alpine \ ash -c 'tar -xvf /ssh_data/test-hostnametoken.tar.gz -C /tmp && test -f /tmp/backup/app_data/offen.db' @@ -34,7 +34,7 @@ sleep 5 docker compose exec backup backup docker run --rm \ - -v ssh_backup_data:/ssh_data \ + -v ssh_ssh_backup_data:/ssh_data \ alpine \ ash -c '[ $(find /ssh_data/ -type f | wc -l) = "1" ]' @@ -51,7 +51,7 @@ docker compose exec backup backup # Set the modification date of the old backup to 14 days ago docker run --rm \ - -v ssh_backup_data:/ssh_data \ + -v ssh_ssh_backup_data:/ssh_data \ --user 1000 \ alpine \ ash -c 'touch -d@$(( $(date +%s) - 1209600 )) /ssh_data/test-hostnametoken-old.tar.gz' @@ -60,7 +60,7 @@ info "Create second backup and prune" docker compose exec backup backup docker run --rm \ - -v ssh_backup_data:/ssh_data \ + -v ssh_ssh_backup_data:/ssh_data \ alpine \ ash -c 'test ! -f /ssh_data/test-hostnametoken-old.tar.gz && test -f /ssh_data/test-hostnametoken.tar.gz'