From 3cdfe648eb14e20fa5383b52076b38af94dbbc8c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 2 Sep 2026 11:33:25 -0700 Subject: [PATCH] sftp: reject an empty password (#11095) ValidatePassword compared the stored and supplied passwords with subtle.ConstantTimeCompare, which returns 1 for two zero-length slices. A user provisioned for public-key-only auth has an empty stored password, so an empty supplied password authenticated as that user whenever "password" was among the enabled auth methods (the default). Treat an empty stored or supplied password as a non-match. Claude-Session: https://claude.ai/code/session_011QqNaxZwnpHgMoAZNp3RkY --- weed/sftpd/user/filestore.go | 5 +++++ weed/sftpd/user/filestore_test.go | 36 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 weed/sftpd/user/filestore_test.go diff --git a/weed/sftpd/user/filestore.go b/weed/sftpd/user/filestore.go index 5bd0b0513..24723fe8d 100644 --- a/weed/sftpd/user/filestore.go +++ b/weed/sftpd/user/filestore.go @@ -154,6 +154,11 @@ func (s *FileStore) ValidatePassword(username string, password []byte) bool { return false } + // An empty stored or supplied password is never a match: a public-key-only + // user has no password, and equal-length zero slices would otherwise compare equal. + if len(user.Password) == 0 || len(password) == 0 { + return false + } // Compare plaintext password using constant time comparison for security return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1 } diff --git a/weed/sftpd/user/filestore_test.go b/weed/sftpd/user/filestore_test.go new file mode 100644 index 000000000..9c097c420 --- /dev/null +++ b/weed/sftpd/user/filestore_test.go @@ -0,0 +1,36 @@ +package user + +import "testing" + +func newTestStore(users ...*User) *FileStore { + s := &FileStore{users: make(map[string]*User)} + for _, u := range users { + s.users[u.Username] = u + } + return s +} + +func TestValidatePasswordRejectsEmpty(t *testing.T) { + s := newTestStore( + &User{Username: "keyonly", Password: "", PublicKeys: []string{"ssh-ed25519 AAAA"}}, + &User{Username: "haspass", Password: "s3cret"}, + ) + + cases := []struct { + username string + password string + want bool + }{ + {"keyonly", "", false}, // public-key-only user must not accept an empty password + {"keyonly", "wrong", false}, + {"haspass", "", false}, // a real password is never matched by an empty one + {"haspass", "s3cret", true}, + {"haspass", "wrong", false}, + {"missing", "", false}, + } + for _, c := range cases { + if got := s.ValidatePassword(c.username, []byte(c.password)); got != c.want { + t.Errorf("ValidatePassword(%q, %q) = %v, want %v", c.username, c.password, got, c.want) + } + } +}