Skip to content

Commit 2ee408b

Browse files
tributcorylanouclaude
authored
SSH host key check (#609)
Co-authored-by: Cory LaNou <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 6c5f569 commit 2ee408b

File tree

6 files changed

+186
-1
lines changed

6 files changed

+186
-1
lines changed

cmd/litestream/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ type ReplicaConfig struct {
584584
Password string `yaml:"password"`
585585
KeyPath string `yaml:"key-path"`
586586
ConcurrentWrites *bool `yaml:"concurrent-writes"`
587+
HostKey string `yaml:"host-key"`
587588

588589
// NATS settings
589590
JWT string `yaml:"jwt"`
@@ -910,6 +911,7 @@ func newSFTPReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_
910911
client.Password = password
911912
client.Path = path
912913
client.KeyPath = c.KeyPath
914+
client.HostKey = c.HostKey
913915

914916
// Set concurrent writes if specified, otherwise use default (true)
915917
if c.ConcurrentWrites != nil {

cmd/litestream/main_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/benbjohnson/litestream/file"
1414
"github.com/benbjohnson/litestream/gs"
1515
"github.com/benbjohnson/litestream/s3"
16+
"github.com/benbjohnson/litestream/sftp"
1617
)
1718

1819
func TestOpenConfigFile(t *testing.T) {
@@ -217,6 +218,27 @@ func TestNewGSReplicaFromConfig(t *testing.T) {
217218
}
218219
}
219220

221+
func TestNewSFTPReplicaFromConfig(t *testing.T) {
222+
hostKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAnK0+GdwOelXlAXdqLx/qvS7WHMr3rH7zW2+0DtmK5r"
223+
r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{
224+
URL: "sftp://[email protected]:2222/foo",
225+
HostKey: hostKey,
226+
}, nil)
227+
if err != nil {
228+
t.Fatal(err)
229+
} else if client, ok := r.Client.(*sftp.ReplicaClient); !ok {
230+
t.Fatal("unexpected replica type")
231+
} else if got, want := client.HostKey, hostKey; got != want {
232+
t.Fatalf("HostKey=%s, want %s", got, want)
233+
} else if got, want := client.Host, "example.com:2222"; got != want {
234+
t.Fatalf("Host=%s, want %s", got, want)
235+
} else if got, want := client.User, "user"; got != want {
236+
t.Fatalf("User=%s, want %s", got, want)
237+
} else if got, want := client.Path, "/foo"; got != want {
238+
t.Fatalf("Path=%s, want %s", got, want)
239+
}
240+
}
241+
220242
// TestNewReplicaFromConfig_AgeEncryption verifies that age encryption configuration is rejected.
221243
// Age encryption is currently non-functional and would silently write plaintext data.
222244
// See: https://github.com/benbjohnson/litestream/issues/790

etc/litestream.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,11 @@
1414
# # client-cert: /path/to/client.pem
1515
# # client-key: /path/to/client.key
1616
# # root-cas: [/path/to/ca.pem]
17+
# - url: sftp://user@host:22/path # SFTP-based replication
18+
# key-path: /etc/litestream/sftp_key
19+
# # Strongly recommended: SSH host key for verification
20+
# # Get this from the server's /etc/ssh/ssh_host_*.pub file
21+
# # or use `ssh-keyscan hostname`
22+
# # Example formats:
23+
# # host-key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvvypUkBrS9RCyV//p+UFCLg8yKNtTu/ew/cV6XXAAP
24+
# # host-key: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ...

internal/testingutil/testingutil.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import (
55
"database/sql"
66
"flag"
77
"fmt"
8+
"io"
89
"log/slog"
910
"math/rand/v2"
11+
"net"
1012
"os"
1113
"path"
1214
"path/filepath"
1315
"strings"
1416
"testing"
1517

18+
sftpserver "github.com/pkg/sftp"
19+
"golang.org/x/crypto/ssh"
20+
1621
"github.com/benbjohnson/litestream"
1722
"github.com/benbjohnson/litestream/abs"
1823
"github.com/benbjohnson/litestream/file"
@@ -295,3 +300,61 @@ func MustDeleteAll(tb testing.TB, c litestream.ReplicaClient) {
295300
}
296301
}
297302
}
303+
304+
func MockSFTPServer(t *testing.T, hostKey ssh.Signer) string {
305+
config := &ssh.ServerConfig{NoClientAuth: true}
306+
config.AddHostKey(hostKey)
307+
308+
listener, err := net.Listen("tcp", "127.0.0.1:0") // random available port
309+
if err != nil {
310+
t.Fatal(err)
311+
}
312+
313+
go func() {
314+
for {
315+
conn, err := listener.Accept()
316+
if err != nil {
317+
return
318+
}
319+
320+
go func() {
321+
_, chans, reqs, err := ssh.NewServerConn(conn, config)
322+
if err != nil {
323+
return
324+
}
325+
go ssh.DiscardRequests(reqs)
326+
327+
for ch := range chans {
328+
if ch.ChannelType() != "session" {
329+
ch.Reject(ssh.UnknownChannelType, "unsupported")
330+
continue
331+
}
332+
channel, requests, err := ch.Accept()
333+
if err != nil {
334+
return
335+
}
336+
337+
go func(in <-chan *ssh.Request) {
338+
for req := range in {
339+
if req.Type == "subsystem" && string(req.Payload[4:]) == "sftp" {
340+
req.Reply(true, nil)
341+
342+
server, err := sftpserver.NewServer(channel)
343+
if err != nil {
344+
return
345+
}
346+
if err := server.Serve(); err != nil && err != io.EOF {
347+
t.Logf("SFTP server error: %v", err)
348+
}
349+
return
350+
}
351+
req.Reply(false, nil)
352+
}
353+
}(requests)
354+
}
355+
}()
356+
}
357+
}()
358+
359+
return listener.Addr().String()
360+
}

replica_client_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import (
44
"bytes"
55
"context"
66
"io"
7+
"log/slog"
78
"os"
89
"slices"
910
"strings"
1011
"testing"
1112
"time"
1213

1314
"github.com/superfly/ltx"
15+
"golang.org/x/crypto/ssh"
1416

1517
"github.com/benbjohnson/litestream"
1618
"github.com/benbjohnson/litestream/internal/testingutil"
@@ -417,3 +419,79 @@ func TestReplicaClient_S3_BucketValidation(t *testing.T) {
417419
t.Errorf("expected bucket validation error, got: %v", err)
418420
}
419421
}
422+
423+
func TestReplicaClient_SFTP_HostKeyValidation(t *testing.T) {
424+
testHostKeyPEM := `-----BEGIN OPENSSH PRIVATE KEY-----
425+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
426+
QyNTUxOQAAACAJytPhncDnpV5QF3ai8f6r0u1hzK96x+81tvtA7ZiuawAAAJAIcGGVCHBh
427+
lQAAAAtzc2gtZWQyNTUxOQAAACAJytPhncDnpV5QF3ai8f6r0u1hzK96x+81tvtA7Ziuaw
428+
AAAEDzV1D6COyvFGhSiZa6ll9aXZ2IMWED3KGrvCNjEEtYHwnK0+GdwOelXlAXdqLx/qvS
429+
7WHMr3rH7zW2+0DtmK5rAAAADGZlbGl4QGJvcmVhcwE=
430+
-----END OPENSSH PRIVATE KEY-----`
431+
privateKey, err := ssh.ParsePrivateKey([]byte(testHostKeyPEM))
432+
if err != nil {
433+
t.Fatal(err)
434+
}
435+
436+
t.Run("ValidHostKey", func(t *testing.T) {
437+
addr := testingutil.MockSFTPServer(t, privateKey)
438+
expectedHostKey := string(ssh.MarshalAuthorizedKey(privateKey.PublicKey()))
439+
440+
c := testingutil.NewSFTPReplicaClient(t)
441+
c.User = "foo"
442+
c.Host = addr
443+
c.HostKey = expectedHostKey
444+
445+
_, err = c.Init(context.Background())
446+
if err != nil {
447+
t.Fatalf("SFTP connection failed: %v", err)
448+
}
449+
})
450+
t.Run("InvalidHostKey", func(t *testing.T) {
451+
addr := testingutil.MockSFTPServer(t, privateKey)
452+
invalidHostKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEqM2NkGvKKhR1oiKO0E72L3tOsYk+aX7H8Xn4bbZKsa"
453+
454+
c := testingutil.NewSFTPReplicaClient(t)
455+
c.User = "foo"
456+
c.Host = addr
457+
c.HostKey = invalidHostKey
458+
459+
_, err = c.Init(context.Background())
460+
if err == nil {
461+
t.Fatalf("SFTP connection established despite invalid host key")
462+
}
463+
if !strings.Contains(err.Error(), "ssh: host key mismatch") {
464+
t.Errorf("expected host key validation error, got: %v", err)
465+
}
466+
})
467+
t.Run("IgnoreHostKey", func(t *testing.T) {
468+
var captured []string
469+
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{
470+
Level: slog.LevelWarn,
471+
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
472+
if a.Key == slog.MessageKey {
473+
captured = append(captured, a.Value.String())
474+
}
475+
return a
476+
},
477+
})))
478+
479+
addr := testingutil.MockSFTPServer(t, privateKey)
480+
481+
c := testingutil.NewSFTPReplicaClient(t)
482+
c.User = "foo"
483+
c.Host = addr
484+
485+
_, err = c.Init(context.Background())
486+
if err != nil {
487+
t.Fatalf("SFTP connection failed: %v", err)
488+
}
489+
490+
if !slices.ContainsFunc(captured, func(msg string) bool {
491+
return strings.Contains(msg, "sftp host key not verified")
492+
}) {
493+
t.Errorf("Expected warning not found")
494+
}
495+
496+
})
497+
}

sftp/replica_client.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type ReplicaClient struct {
4444
Password string
4545
Path string
4646
KeyPath string
47+
HostKey string
4748
DialTimeout time.Duration
4849

4950
// ConcurrentWrites enables concurrent writes for better performance.
@@ -79,9 +80,20 @@ func (c *ReplicaClient) Init(ctx context.Context) (_ *sftp.Client, err error) {
7980
}
8081

8182
// Build SSH configuration & auth methods
83+
var hostkey ssh.HostKeyCallback
84+
if c.HostKey != "" {
85+
var pubkey, _, _, _, err = ssh.ParseAuthorizedKey([]byte(c.HostKey))
86+
if err != nil {
87+
return nil, fmt.Errorf("cannot parse sftp host key: %w", err)
88+
}
89+
hostkey = ssh.FixedHostKey(pubkey)
90+
} else {
91+
slog.Warn("sftp host key not verified", "host", c.Host)
92+
hostkey = ssh.InsecureIgnoreHostKey()
93+
}
8294
config := &ssh.ClientConfig{
8395
User: c.User,
84-
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
96+
HostKeyCallback: hostkey,
8597
BannerCallback: ssh.BannerDisplayStderr(),
8698
}
8799
if c.Password != "" {

0 commit comments

Comments
 (0)