aboutsummaryrefslogtreecommitdiff
path: root/weed/sftpd
diff options
context:
space:
mode:
Diffstat (limited to 'weed/sftpd')
-rw-r--r--weed/sftpd/sftp_file_writer.go5
-rw-r--r--weed/sftpd/sftp_filer.go82
-rw-r--r--weed/sftpd/sftp_server.go24
-rw-r--r--weed/sftpd/sftp_server_test.go103
-rw-r--r--weed/sftpd/sftp_service.go4
-rw-r--r--weed/sftpd/user/filestore.go5
6 files changed, 191 insertions, 32 deletions
diff --git a/weed/sftpd/sftp_file_writer.go b/weed/sftpd/sftp_file_writer.go
index 0a662d021..fed60eec0 100644
--- a/weed/sftpd/sftp_file_writer.go
+++ b/weed/sftpd/sftp_file_writer.go
@@ -72,6 +72,7 @@ func (l listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
type SeaweedSftpFileWriter struct {
fs SftpServer
req *sftp.Request
+ absPath string // Absolute path after HomeDir translation
mu sync.Mutex
tmpFile *os.File
permissions os.FileMode
@@ -105,6 +106,6 @@ func (w *SeaweedSftpFileWriter) Close() error {
return err
}
- // Stream the file instead of loading it
- return w.fs.putFile(w.req.Filepath, w.tmpFile, w.fs.user)
+ // Stream the file to the absolute path (after HomeDir translation)
+ return w.fs.putFile(w.absPath, w.tmpFile, w.fs.user)
}
diff --git a/weed/sftpd/sftp_filer.go b/weed/sftpd/sftp_filer.go
index 9baaf41d7..eb196cc28 100644
--- a/weed/sftpd/sftp_filer.go
+++ b/weed/sftpd/sftp_filer.go
@@ -100,18 +100,26 @@ func (fs *SftpServer) withTimeoutContext(fn func(ctx context.Context) error) err
// ==================== Command Dispatcher ====================
func (fs *SftpServer) dispatchCmd(r *sftp.Request) error {
- glog.V(0).Infof("Dispatch: %s %s", r.Method, r.Filepath)
+ absPath, err := fs.toAbsolutePath(r.Filepath)
+ if err != nil {
+ return err
+ }
+ glog.V(1).Infof("Dispatch: %s %s (absolute: %s)", r.Method, r.Filepath, absPath)
switch r.Method {
case "Remove":
- return fs.removeEntry(r)
+ return fs.removeEntry(absPath)
case "Rename":
- return fs.renameEntry(r)
+ absTarget, err := fs.toAbsolutePath(r.Target)
+ if err != nil {
+ return err
+ }
+ return fs.renameEntry(absPath, absTarget)
case "Mkdir":
- return fs.makeDir(r)
+ return fs.makeDir(absPath)
case "Rmdir":
- return fs.removeDir(r)
+ return fs.removeDir(absPath)
case "Setstat":
- return fs.setFileStat(r)
+ return fs.setFileStatWithRequest(absPath, r)
default:
return fmt.Errorf("unsupported: %s", r.Method)
}
@@ -120,10 +128,14 @@ func (fs *SftpServer) dispatchCmd(r *sftp.Request) error {
// ==================== File Operations ====================
func (fs *SftpServer) readFile(r *sftp.Request) (io.ReaderAt, error) {
- if err := fs.checkFilePermission(r.Filepath, "read"); err != nil {
+ absPath, err := fs.toAbsolutePath(r.Filepath)
+ if err != nil {
+ return nil, err
+ }
+ if err := fs.checkFilePermission(absPath, "read"); err != nil {
return nil, err
}
- entry, err := fs.getEntry(r.Filepath)
+ entry, err := fs.getEntry(absPath)
if err != nil {
return nil, err
}
@@ -131,7 +143,11 @@ func (fs *SftpServer) readFile(r *sftp.Request) (io.ReaderAt, error) {
}
func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
- dir, _ := util.FullPath(r.Filepath).DirAndName()
+ absPath, err := fs.toAbsolutePath(r.Filepath)
+ if err != nil {
+ return nil, err
+ }
+ dir, _ := util.FullPath(absPath).DirAndName()
if err := fs.checkFilePermission(dir, "write"); err != nil {
glog.Errorf("Permission denied for %s", dir)
return nil, err
@@ -145,6 +161,7 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
return &SeaweedSftpFileWriter{
fs: *fs,
req: r,
+ absPath: absPath,
tmpFile: tmpFile,
permissions: 0644,
uid: fs.user.Uid,
@@ -153,16 +170,20 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
}, nil
}
-func (fs *SftpServer) removeEntry(r *sftp.Request) error {
- return fs.deleteEntry(r.Filepath, false)
+func (fs *SftpServer) removeEntry(absPath string) error {
+ return fs.deleteEntry(absPath, false)
}
-func (fs *SftpServer) renameEntry(r *sftp.Request) error {
- if err := fs.checkFilePermission(r.Filepath, "rename"); err != nil {
+func (fs *SftpServer) renameEntry(absPath, absTarget string) error {
+ if err := fs.checkFilePermission(absPath, "rename"); err != nil {
+ return err
+ }
+ targetDir, _ := util.FullPath(absTarget).DirAndName()
+ if err := fs.checkFilePermission(targetDir, "write"); err != nil {
return err
}
- oldDir, oldName := util.FullPath(r.Filepath).DirAndName()
- newDir, newName := util.FullPath(r.Target).DirAndName()
+ oldDir, oldName := util.FullPath(absPath).DirAndName()
+ newDir, newName := util.FullPath(absTarget).DirAndName()
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
OldDirectory: oldDir, OldName: oldName,
@@ -172,15 +193,15 @@ func (fs *SftpServer) renameEntry(r *sftp.Request) error {
})
}
-func (fs *SftpServer) setFileStat(r *sftp.Request) error {
- if err := fs.checkFilePermission(r.Filepath, "write"); err != nil {
+func (fs *SftpServer) setFileStatWithRequest(absPath string, r *sftp.Request) error {
+ if err := fs.checkFilePermission(absPath, "write"); err != nil {
return err
}
- entry, err := fs.getEntry(r.Filepath)
+ entry, err := fs.getEntry(absPath)
if err != nil {
return err
}
- dir, _ := util.FullPath(r.Filepath).DirAndName()
+ dir, _ := util.FullPath(absPath).DirAndName()
// apply attrs
if r.AttrFlags().Permissions {
entry.Attributes.FileMode = uint32(r.Attributes().FileMode())
@@ -201,18 +222,22 @@ func (fs *SftpServer) setFileStat(r *sftp.Request) error {
// ==================== Directory Operations ====================
func (fs *SftpServer) listDir(r *sftp.Request) (sftp.ListerAt, error) {
- if err := fs.checkFilePermission(r.Filepath, "list"); err != nil {
+ absPath, err := fs.toAbsolutePath(r.Filepath)
+ if err != nil {
+ return nil, err
+ }
+ if err := fs.checkFilePermission(absPath, "list"); err != nil {
return nil, err
}
if r.Method == "Stat" || r.Method == "Lstat" {
- entry, err := fs.getEntry(r.Filepath)
+ entry, err := fs.getEntry(absPath)
if err != nil {
return nil, err
}
fi := &EnhancedFileInfo{FileInfo: FileInfoFromEntry(entry), uid: entry.Attributes.Uid, gid: entry.Attributes.Gid}
return listerat([]os.FileInfo{fi}), nil
}
- return fs.listAllPages(r.Filepath)
+ return fs.listAllPages(absPath)
}
func (fs *SftpServer) listAllPages(dirPath string) (sftp.ListerAt, error) {
@@ -259,18 +284,19 @@ func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo,
}
// makeDir creates a new directory with proper permissions.
-func (fs *SftpServer) makeDir(r *sftp.Request) error {
+func (fs *SftpServer) makeDir(absPath string) error {
if fs.user == nil {
return fmt.Errorf("cannot create directory: no user info")
}
- dir, name := util.FullPath(r.Filepath).DirAndName()
- if err := fs.checkFilePermission(r.Filepath, "mkdir"); err != nil {
+ dir, name := util.FullPath(absPath).DirAndName()
+ if err := fs.checkFilePermission(dir, "write"); err != nil {
return err
}
// default mode and ownership
err := filer_pb.Mkdir(context.Background(), fs, string(dir), name, func(entry *filer_pb.Entry) {
mode := uint32(0755 | os.ModeDir)
- if strings.HasPrefix(r.Filepath, fs.user.HomeDir) {
+ // Defensive check: all paths should be under HomeDir after toAbsolutePath translation
+ if absPath == fs.user.HomeDir || strings.HasPrefix(absPath, fs.user.HomeDir+"/") {
mode = uint32(0700 | os.ModeDir)
}
entry.Attributes.FileMode = mode
@@ -288,8 +314,8 @@ func (fs *SftpServer) makeDir(r *sftp.Request) error {
}
// removeDir deletes a directory.
-func (fs *SftpServer) removeDir(r *sftp.Request) error {
- return fs.deleteEntry(r.Filepath, false)
+func (fs *SftpServer) removeDir(absPath string) error {
+ return fs.deleteEntry(absPath, false)
}
func (fs *SftpServer) putFile(filepath string, reader io.Reader, user *user.User) error {
diff --git a/weed/sftpd/sftp_server.go b/weed/sftpd/sftp_server.go
index f158aeb64..e53098e6b 100644
--- a/weed/sftpd/sftp_server.go
+++ b/weed/sftpd/sftp_server.go
@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"os"
+ "path"
+ "strings"
"time"
"github.com/pkg/sftp"
@@ -37,6 +39,28 @@ func NewSftpServer(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, d
}
}
+// toAbsolutePath translates a user-relative path to an absolute filer path.
+// When a user has HomeDir="/sftp/user", their view of "/" maps to "/sftp/user".
+// This implements chroot-like behavior where the user's home directory
+// becomes their root.
+func (fs *SftpServer) toAbsolutePath(userPath string) (string, error) {
+ // If user has root as home directory, no translation needed
+ if fs.user.HomeDir == "" || fs.user.HomeDir == "/" {
+ return path.Clean(userPath), nil
+ }
+
+ // Concatenate home directory with user path, then clean to resolve any ".." components
+ p := path.Join(fs.user.HomeDir, strings.TrimPrefix(userPath, "/"))
+
+ // Security check: ensure the final path is within the home directory.
+ // This prevents path traversal attacks like `../..` that could escape the chroot jail.
+ if !strings.HasPrefix(p, fs.user.HomeDir+"/") && p != fs.user.HomeDir {
+ return "", fmt.Errorf("path traversal attempt: %s resolves to %s which is outside home dir %s", userPath, p, fs.user.HomeDir)
+ }
+
+ return p, nil
+}
+
// Fileread is invoked for “get” requests.
func (fs *SftpServer) Fileread(req *sftp.Request) (io.ReaderAt, error) {
return fs.readFile(req)
diff --git a/weed/sftpd/sftp_server_test.go b/weed/sftpd/sftp_server_test.go
new file mode 100644
index 000000000..0af94ca14
--- /dev/null
+++ b/weed/sftpd/sftp_server_test.go
@@ -0,0 +1,103 @@
+package sftpd
+
+import (
+ "testing"
+
+ "github.com/seaweedfs/seaweedfs/weed/sftpd/user"
+ "github.com/stretchr/testify/assert"
+)
+
+func stringPtr(s string) *string {
+ return &s
+}
+
+func TestToAbsolutePath(t *testing.T) {
+ tests := []struct {
+ name string
+ homeDir *string // Use pointer to distinguish between unset and empty
+ userPath string
+ expected string
+ expectError bool
+ }{
+ {
+ name: "normal path",
+ userPath: "/foo.txt",
+ expected: "/sftp/testuser/foo.txt",
+ },
+ {
+ name: "root path",
+ userPath: "/",
+ expected: "/sftp/testuser",
+ },
+ {
+ name: "path with dot",
+ userPath: "/./foo.txt",
+ expected: "/sftp/testuser/foo.txt",
+ },
+ {
+ name: "path traversal attempts",
+ userPath: "/../foo.txt",
+ expectError: true,
+ },
+ {
+ name: "path traversal attempts 2",
+ userPath: "../../foo.txt",
+ expectError: true,
+ },
+ {
+ name: "path traversal attempts 3",
+ userPath: "/subdir/../../foo.txt",
+ expectError: true,
+ },
+ {
+ name: "empty path",
+ userPath: "",
+ expected: "/sftp/testuser",
+ },
+ {
+ name: "multiple slashes",
+ userPath: "//foo.txt",
+ expected: "/sftp/testuser/foo.txt",
+ },
+ {
+ name: "trailing slash",
+ userPath: "/foo/",
+ expected: "/sftp/testuser/foo",
+ },
+ {
+ name: "empty HomeDir passthrough",
+ homeDir: stringPtr(""),
+ userPath: "/foo.txt",
+ expected: "/foo.txt",
+ },
+ {
+ name: "root HomeDir passthrough",
+ homeDir: stringPtr("/"),
+ userPath: "/foo.txt",
+ expected: "/foo.txt",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ homeDir := "/sftp/testuser" // default
+ if tt.homeDir != nil {
+ homeDir = *tt.homeDir
+ }
+
+ fs := &SftpServer{
+ user: &user.User{
+ HomeDir: homeDir,
+ },
+ }
+
+ got, err := fs.toAbsolutePath(tt.userPath)
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expected, got)
+ }
+ })
+ }
+}
diff --git a/weed/sftpd/sftp_service.go b/weed/sftpd/sftp_service.go
index e50bd87ba..4d21815a9 100644
--- a/weed/sftpd/sftp_service.go
+++ b/weed/sftpd/sftp_service.go
@@ -284,8 +284,8 @@ func (s *SFTPService) handleChannel(newChannel ssh.NewChannel, fs *SftpServer) {
// handleSFTP starts the SFTP server on the SSH channel.
func (s *SFTPService) handleSFTP(channel ssh.Channel, fs *SftpServer) {
- // Create server options with initial working directory set to user's home
- serverOptions := sftp.WithStartDirectory(fs.user.HomeDir)
+ // Start at virtual root "/" - toAbsolutePath translates this to the user's HomeDir
+ serverOptions := sftp.WithStartDirectory("/")
server := sftp.NewRequestServer(channel, sftp.Handlers{
FileGet: fs,
FilePut: fs,
diff --git a/weed/sftpd/user/filestore.go b/weed/sftpd/user/filestore.go
index c522a388a..4c372aa76 100644
--- a/weed/sftpd/user/filestore.go
+++ b/weed/sftpd/user/filestore.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
+ "path"
"sync"
"golang.org/x/crypto/ssh"
@@ -99,6 +100,10 @@ func (s *FileStore) loadUsers() error {
user.PublicKeys[i] = string(pubKey.Marshal())
}
}
+ // Clean HomeDir to handle trailing slashes and normalize path
+ if user.HomeDir != "" {
+ user.HomeDir = path.Clean(user.HomeDir)
+ }
s.users[user.Username] = user
}