浏览代码

sftpd: add folder prefix middleware

mmcgeefeedo 4 年之前
父节点
当前提交
3ae8abda9e
共有 9 个文件被更改,包括 740 次插入19 次删除
  1. 2 0
      config/config.go
  2. 1 0
      go.mod
  3. 1 0
      go.sum
  4. 229 0
      sftpd/middleware.go
  5. 328 0
      sftpd/middleware_test.go
  6. 140 0
      sftpd/mocks/middleware.go
  7. 36 17
      sftpd/server.go
  8. 1 1
      sftpd/ssh_cmd.go
  9. 2 1
      sftpgo.json

+ 2 - 0
config/config.go

@@ -153,6 +153,7 @@ func Init() {
 			EnabledSSHCommands:      sftpd.GetDefaultSSHCommands(),
 			KeyboardInteractiveHook: "",
 			PasswordAuthentication:  true,
+			FolderPrefix:            "",
 		},
 		FTPD: ftpd.Configuration{
 			Bindings:                 []ftpd.Binding{defaultFTPDBinding},
@@ -977,6 +978,7 @@ func setViperDefaults() {
 	viper.SetDefault("sftpd.enabled_ssh_commands", globalConf.SFTPD.EnabledSSHCommands)
 	viper.SetDefault("sftpd.keyboard_interactive_auth_hook", globalConf.SFTPD.KeyboardInteractiveHook)
 	viper.SetDefault("sftpd.password_authentication", globalConf.SFTPD.PasswordAuthentication)
+	viper.SetDefault("sftpd.folder_prefix", globalConf.SFTPD.FolderPrefix)
 	viper.SetDefault("ftpd.banner", globalConf.FTPD.Banner)
 	viper.SetDefault("ftpd.banner_file", globalConf.FTPD.BannerFile)
 	viper.SetDefault("ftpd.active_transfers_port_non_20", globalConf.FTPD.ActiveTransfersPortNon20)

+ 1 - 0
go.mod

@@ -18,6 +18,7 @@ require (
 	github.com/go-chi/render v1.0.1
 	github.com/go-ole/go-ole v1.2.5 // indirect
 	github.com/go-sql-driver/mysql v1.6.0
+	github.com/golang/mock v1.6.0
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/grandcat/zeroconf v1.0.0
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect

+ 1 - 0
go.sum

@@ -281,6 +281,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
 github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
 github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
 github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=

+ 229 - 0
sftpd/middleware.go

@@ -0,0 +1,229 @@
+package sftpd
+
+import (
+	"io"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/pkg/sftp"
+
+	"github.com/drakkan/sftpgo/v2/vfs"
+)
+
+// Middleware defines the interface for sftp middlewares
+type Middleware interface {
+	sftp.FileReader
+	sftp.FileWriter
+	sftp.OpenFileWriter
+	sftp.FileCmder
+	sftp.StatVFSFileCmder
+	sftp.FileLister
+	sftp.LstatFileLister
+}
+
+type prefixMatch uint8
+
+const (
+	pathContainsPrefix prefixMatch = iota
+	pathIsPrefixParent
+	pathDiverged
+
+	methodList = "List"
+	methodStat = "Stat"
+)
+
+type prefixMiddleware struct {
+	prefix string
+	next   Middleware
+}
+
+func newPrefixMiddleware(prefix string, next Middleware) Middleware {
+	return &prefixMiddleware{
+		prefix: prefix,
+		next:   next,
+	}
+}
+
+func (p *prefixMiddleware) Lstat(request *sftp.Request) (sftp.ListerAt, error) {
+	switch getPrefixHierarchy(p.prefix, request.Filepath) {
+	case pathContainsPrefix:
+		request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+		return p.next.Lstat(request)
+	case pathIsPrefixParent:
+		return listerAt([]os.FileInfo{
+			vfs.NewFileInfo(request.Filepath, true, 0, time.Now(), false),
+		}), nil
+	default:
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+}
+
+func (p *prefixMiddleware) OpenFile(request *sftp.Request) (sftp.WriterAtReaderAt, error) {
+	switch getPrefixHierarchy(p.prefix, request.Filepath) {
+	case pathContainsPrefix:
+		request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+		return p.next.OpenFile(request)
+	default:
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+}
+
+func (p *prefixMiddleware) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
+	switch getPrefixHierarchy(p.prefix, request.Filepath) {
+	case pathContainsPrefix:
+		request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+		return p.next.Filelist(request)
+	case pathIsPrefixParent:
+		Now := time.Now()
+		switch request.Method {
+		case methodList:
+			FileName := p.nextListFolder(request.Filepath)
+			return listerAt([]os.FileInfo{
+				// vfs.NewFileInfo(`.`, true, 0, Now, false),
+				vfs.NewFileInfo(FileName, true, 0, Now, false),
+			}), nil
+		case methodStat:
+			return listerAt([]os.FileInfo{
+				vfs.NewFileInfo(request.Filepath, true, 0, Now, false),
+			}), nil
+		default:
+			return nil, sftp.ErrSSHFxOpUnsupported
+		}
+	default:
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+}
+
+func (p *prefixMiddleware) Filewrite(request *sftp.Request) (io.WriterAt, error) {
+	switch getPrefixHierarchy(p.prefix, request.Filepath) {
+	case pathContainsPrefix:
+		// forward to next handler
+		request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+		return p.next.Filewrite(request)
+	default:
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+}
+
+func (p *prefixMiddleware) Fileread(request *sftp.Request) (io.ReaderAt, error) {
+	switch getPrefixHierarchy(p.prefix, request.Filepath) {
+	case pathContainsPrefix:
+		request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+		return p.next.Fileread(request)
+	default:
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+}
+
+func (p *prefixMiddleware) Filecmd(request *sftp.Request) error {
+	switch request.Method {
+	case "Rename", "Symlink":
+		if getPrefixHierarchy(p.prefix, request.Filepath) == pathContainsPrefix &&
+			getPrefixHierarchy(p.prefix, request.Target) == pathContainsPrefix {
+			request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+			request.Target, _ = p.removeFolderPrefix(request.Target)
+			return p.next.Filecmd(request)
+		}
+		return sftp.ErrSSHFxPermissionDenied
+	// commands have a source and destination (file path and target path)
+	case "Setstat", "Rmdir", "Mkdir", "Remove":
+		// commands just the file path
+		if getPrefixHierarchy(p.prefix, request.Filepath) == pathContainsPrefix {
+			request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+			return p.next.Filecmd(request)
+		}
+		return sftp.ErrSSHFxPermissionDenied
+	default:
+		return sftp.ErrSSHFxOpUnsupported
+	}
+}
+
+func (p *prefixMiddleware) StatVFS(request *sftp.Request) (*sftp.StatVFS, error) {
+	switch getPrefixHierarchy(p.prefix, request.Filepath) {
+	case pathContainsPrefix:
+		// forward to next handler
+		request.Filepath, _ = p.removeFolderPrefix(request.Filepath)
+		return p.next.StatVFS(request)
+	default:
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+}
+
+func (p *prefixMiddleware) nextListFolder(requestPath string) string {
+	cleanPath := filepath.Clean(`/` + requestPath)
+	cleanPrefix := filepath.Clean(`/` + p.prefix)
+
+	FileName := cleanPrefix[len(cleanPath):]
+	FileName = strings.TrimLeft(FileName, `/`)
+	SlashIndex := strings.Index(FileName, `/`)
+	if SlashIndex > 0 {
+		return FileName[0:SlashIndex]
+	}
+	return FileName
+}
+
+func (p *prefixMiddleware) containsPrefix(virtualPath string) bool {
+	if !path.IsAbs(virtualPath) {
+		virtualPath = path.Clean(`/` + virtualPath)
+	}
+
+	if p.prefix == `/` || p.prefix == `` {
+		return true
+	} else if p.prefix == virtualPath {
+		return true
+	}
+
+	return strings.HasPrefix(virtualPath, p.prefix+`/`)
+}
+
+func (p *prefixMiddleware) removeFolderPrefix(virtualPath string) (string, bool) {
+	if p.prefix == `/` || p.prefix == `` {
+		return virtualPath, true
+	}
+
+	virtualPath = filepath.Clean(`/` + virtualPath)
+	if p.containsPrefix(virtualPath) {
+		effectivePath := virtualPath[len(p.prefix):]
+		if effectivePath == `` {
+			effectivePath = `/`
+		}
+		return effectivePath, true
+	}
+	return virtualPath, false
+}
+
+func getPrefixHierarchy(prefix, path string) prefixMatch {
+	prefixSplit := strings.Split(filepath.Clean(`/`+prefix), `/`)
+	pathSplit := strings.Split(filepath.Clean(`/`+path), `/`)
+
+	for {
+		// stop if either slice is empty of the current head elements do not match
+		if len(prefixSplit) == 0 || len(pathSplit) == 0 ||
+			prefixSplit[0] != pathSplit[0] {
+			break
+		}
+		prefixSplit = prefixSplit[1:]
+		pathSplit = pathSplit[1:]
+	}
+
+	// The entire Prefix is included in Test Path
+	// Example: Prefix (/files) with Test Path (/files/test.csv)
+	if len(prefixSplit) == 0 ||
+		(len(prefixSplit) == 1 && prefixSplit[0] == ``) {
+		return pathContainsPrefix
+	}
+
+	// Test Path is part of the Prefix Hierarchy
+	// Example: Prefix (/files) with Test Path (/)
+	if len(pathSplit) == 0 ||
+		(len(pathSplit) == 1 && pathSplit[0] == ``) {
+		return pathIsPrefixParent
+	}
+
+	// Test Path is not with the Prefix Hierarchy
+	// Example: Prefix (/files) with Test Path (/files2)
+	return pathDiverged
+}

+ 328 - 0
sftpd/middleware_test.go

@@ -0,0 +1,328 @@
+package sftpd
+
+import (
+	"testing"
+	"time"
+
+	"github.com/golang/mock/gomock"
+	"github.com/pkg/sftp"
+	"github.com/stretchr/testify/suite"
+
+	"github.com/drakkan/sftpgo/v2/sftpd/mocks"
+)
+
+type PrefixMiddlewareSuite struct {
+	suite.Suite
+	MockCtl *gomock.Controller
+}
+
+func (Suite *PrefixMiddlewareSuite) BeforeTest(_, _ string) {
+	Suite.MockCtl = gomock.NewController(Suite.T())
+}
+
+func (Suite *PrefixMiddlewareSuite) AfterTest(_, _ string) {
+	Suite.MockCtl.Finish()
+}
+
+func (Suite *PrefixMiddlewareSuite) TestFileWriter() {
+	prefix := prefixMiddleware{prefix: `/files`}
+
+	// parent of prefix
+	WriterAt, err := prefix.Filewrite(&sftp.Request{Filepath: `/`})
+	Suite.Nil(WriterAt)
+	Suite.Equal(sftp.ErrSSHFxPermissionDenied, err)
+
+	// file path and prefix are unrelated
+	WriterAt, err = prefix.Filewrite(&sftp.Request{Filepath: `/random`})
+	Suite.Nil(WriterAt)
+	Suite.Equal(sftp.ErrSSHFxPermissionDenied, err)
+
+	// file path is sub path of configured prefix
+	// mocked returns are not import, just the call to the next file writer
+	mockedWriter := mocks.NewMockMiddleware(Suite.MockCtl)
+	mockedWriter.EXPECT().
+		Filewrite(&sftp.Request{Filepath: `/data`}).
+		Return(nil, nil)
+	prefix.next = mockedWriter
+	WriterAt, err = prefix.Filewrite(&sftp.Request{Filepath: `/files/data`})
+	Suite.Nil(err)
+	Suite.Nil(WriterAt)
+}
+
+func (Suite *PrefixMiddlewareSuite) TestFileReader() {
+	middleware := prefixMiddleware{prefix: `/files`}
+
+	// parent of prefix
+	ReaderAt, err := middleware.Fileread(&sftp.Request{Filepath: `/`})
+	Suite.Nil(ReaderAt)
+	Suite.Equal(sftp.ErrSSHFxPermissionDenied, err)
+
+	// file path and prefix are unrelated
+	ReaderAt, err = middleware.Fileread(&sftp.Request{Filepath: `/random`})
+	Suite.Nil(ReaderAt)
+	Suite.Equal(sftp.ErrSSHFxPermissionDenied, err)
+
+	// file path is sub path of configured prefix
+	// mocked returns are not import, just the call to the next file writer
+	mockedReader := mocks.NewMockMiddleware(Suite.MockCtl)
+	mockedReader.EXPECT().
+		Fileread(&sftp.Request{Filepath: `/data`}).
+		Return(nil, nil)
+	middleware.next = mockedReader
+	ReaderAt, err = middleware.Fileread(&sftp.Request{Filepath: `/files/data`})
+	Suite.Nil(err)
+	Suite.Nil(ReaderAt)
+}
+
+func (Suite *PrefixMiddlewareSuite) TestOpenFile() {
+	middleware := prefixMiddleware{prefix: `/files`}
+
+	ReadWriteAt, err := middleware.OpenFile(&sftp.Request{Filepath: `/`})
+	Suite.Nil(ReadWriteAt)
+	Suite.Equal(sftp.ErrSSHFxPermissionDenied, err)
+
+	// file path and prefix are unrelated
+	ReadWriteAt, err = middleware.OpenFile(&sftp.Request{Filepath: `/random`})
+	Suite.Nil(ReadWriteAt)
+	Suite.Equal(sftp.ErrSSHFxPermissionDenied, err)
+
+	var tests = []struct {
+		RequestPath string
+		NextPath    string
+	}{
+		// test normalization of various request paths
+		{RequestPath: `/files/data.csv`, NextPath: `/data.csv`},
+		{RequestPath: `files/data.csv`, NextPath: `/data.csv`},
+		{RequestPath: `//files/./data.csv`, NextPath: `/data.csv`},
+	}
+
+	for _, test := range tests {
+		OpenFileMock := mocks.NewMockMiddleware(Suite.MockCtl)
+		OpenFileMock.EXPECT().
+			OpenFile(&sftp.Request{Filepath: test.NextPath}).
+			Return(nil, nil)
+		middleware.next = OpenFileMock
+
+		ReadWriteAt, err = middleware.OpenFile(&sftp.Request{Filepath: test.RequestPath})
+		Suite.Nil(ReadWriteAt)
+		Suite.Nil(err)
+	}
+}
+
+func (Suite *PrefixMiddlewareSuite) TestFileListForwarding() {
+	var tests = []struct {
+		Method   string
+		FilePath string
+		FwdPath  string
+	}{
+		{Method: `List`, FilePath: `/files/data`, FwdPath: `/data`},
+		{Method: `List`, FilePath: `/./files/data`, FwdPath: `/data`},
+		{Method: `List`, FilePath: `files/data`, FwdPath: `/data`},
+	}
+
+	for _, test := range tests {
+		FileListMock := mocks.NewMockMiddleware(Suite.MockCtl)
+		FileListMock.EXPECT().
+			Filelist(&sftp.Request{
+				Method:   test.Method,
+				Filepath: test.FwdPath,
+			}).Return(nil, nil)
+
+		handlers := newPrefixMiddleware(`/files`, FileListMock)
+		ListerAt, err := handlers.Filelist(&sftp.Request{
+			Method:   test.Method,
+			Filepath: test.FilePath,
+		})
+		Suite.Nil(ListerAt)
+		Suite.Nil(err)
+	}
+}
+
+func (Suite *PrefixMiddlewareSuite) TestFileList() {
+	var tests = []struct {
+		Method       string
+		FilePath     string
+		ExpectedErr  error
+		ExpectedPath string
+	}{
+		{Method: `List`, FilePath: `/random`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `List`, FilePath: `/`, ExpectedPath: `files`},
+		{Method: `Stat`, FilePath: `/`, ExpectedPath: `/`},
+		{Method: `NotAnOp`, ExpectedErr: sftp.ErrSSHFxOpUnsupported},
+	}
+
+	for _, test := range tests {
+		middleware := prefixMiddleware{prefix: `/files`}
+		ListerAt, err := middleware.Filelist(&sftp.Request{
+			Method:   test.Method,
+			Filepath: test.FilePath,
+		})
+		if test.ExpectedErr != nil {
+			Suite.Equal(test.ExpectedErr, err)
+			Suite.Nil(ListerAt)
+		} else {
+			Suite.Nil(err)
+			Suite.IsType(listerAt{}, ListerAt)
+			if directList, ok := ListerAt.(listerAt); ok {
+				Suite.Len(directList, 1)
+				Suite.Equal(test.ExpectedPath, directList[0].Name())
+				Suite.InDelta(time.Now().Unix(), directList[0].ModTime().Unix(), 1)
+				Suite.True(directList[0].IsDir())
+			}
+		}
+	}
+}
+
+func (Suite *PrefixMiddlewareSuite) TestLstat() {
+	middleware := prefixMiddleware{prefix: `/files`}
+	ListerAt, err := middleware.Lstat(&sftp.Request{Filepath: `/`})
+	Suite.Nil(err)
+	Suite.IsType(listerAt{}, ListerAt)
+	if directList, ok := ListerAt.(listerAt); ok {
+		Suite.Len(directList, 1)
+		Suite.Equal(`/`, directList[0].Name())
+		Suite.InDelta(time.Now().Unix(), directList[0].ModTime().Unix(), 1)
+		Suite.True(directList[0].IsDir())
+	}
+
+	middleware = prefixMiddleware{prefix: `/files`}
+	ListerAt, err = middleware.Lstat(&sftp.Request{Filepath: `/random`})
+	Suite.Nil(ListerAt)
+	Suite.Equal(sftp.ErrSSHFxPermissionDenied, err)
+
+	MockLstat := mocks.NewMockMiddleware(Suite.MockCtl)
+	MockLstat.EXPECT().
+		Lstat(&sftp.Request{Filepath: "/data"}).
+		Return(nil, nil)
+	middleware = prefixMiddleware{prefix: `/files`}
+	middleware.next = MockLstat
+
+	ListerAt, err = middleware.Lstat(&sftp.Request{Filepath: `/files/data`})
+	Suite.Nil(err)
+	Suite.Nil(ListerAt)
+}
+
+func (Suite *PrefixMiddlewareSuite) TestFileCmdForwarding() {
+	var tests = []struct {
+		Method        string
+		FilePath      string
+		TargetPath    string
+		FwdFilePath   string
+		FwdTargetPath string
+	}{
+		{Method: `Rename`, FilePath: `/files/data.csv`, TargetPath: `/files/new-data.csv`, FwdFilePath: `/data.csv`, FwdTargetPath: `/new-data.csv`},
+		{Method: `Rename`, FilePath: `files/data.csv`, TargetPath: `files/new-data.csv`, FwdFilePath: `/data.csv`, FwdTargetPath: `/new-data.csv`},
+		{Method: `Symlink`, FilePath: `/./files/data.csv`, TargetPath: `files/new-data.csv`, FwdFilePath: `/data.csv`, FwdTargetPath: `/new-data.csv`},
+
+		{Method: `Setstat`, FilePath: `files/data.csv`, FwdFilePath: `/data.csv`},
+		{Method: `Remove`, FilePath: `/./files/data.csv`, FwdFilePath: `/data.csv`},
+		{Method: `Rmdir`, FilePath: `files/data`, FwdFilePath: `/data`},
+		{Method: `Mkdir`, FilePath: `/./files/data`, FwdFilePath: `/data`},
+	}
+
+	for _, test := range tests {
+		FileCmdMock := mocks.NewMockMiddleware(Suite.MockCtl)
+		FileCmdMock.EXPECT().
+			Filecmd(&sftp.Request{
+				Method:   test.Method,
+				Filepath: test.FwdFilePath,
+				Target:   test.FwdTargetPath,
+			}).Return(nil)
+
+		middleware := prefixMiddleware{
+			prefix: `/files`,
+			next:   FileCmdMock,
+		}
+
+		Suite.Nil(middleware.Filecmd(&sftp.Request{
+			Method:   test.Method,
+			Filepath: test.FilePath,
+			Target:   test.TargetPath,
+		}))
+	}
+}
+
+func (Suite *PrefixMiddlewareSuite) TestFileCmdErrors() {
+	middleware := prefixMiddleware{prefix: `/files`}
+
+	var tests = []struct {
+		Method      string
+		RequestPath string
+		TargetPath  string
+		ExpectedErr error
+	}{
+		// two path methods
+		{Method: `Rename`, RequestPath: `/`, TargetPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Rename`, RequestPath: `/random`, TargetPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Rename`, RequestPath: `/random`, TargetPath: `/files`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Symlink`, RequestPath: `/`, TargetPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Symlink`, RequestPath: `/random`, TargetPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Symlink`, RequestPath: `/random`, TargetPath: `/files`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+
+		// single path methods
+		{Method: `Setstat`, RequestPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Setstat`, RequestPath: `/unrelated`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Rmdir`, RequestPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Rmdir`, RequestPath: `/unrelated`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Mkdir`, RequestPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Mkdir`, RequestPath: `/unrelated`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Remove`, RequestPath: `/`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+		{Method: `Remove`, RequestPath: `/unrelated`, ExpectedErr: sftp.ErrSSHFxPermissionDenied},
+
+		{Method: `NotACmd`, ExpectedErr: sftp.ErrSSHFxOpUnsupported},
+	}
+
+	for _, test := range tests {
+		err := middleware.Filecmd(&sftp.Request{
+			Method:   test.Method,
+			Filepath: test.RequestPath,
+			Target:   test.TargetPath,
+		})
+		Suite.Equal(test.ExpectedErr, err)
+	}
+}
+
+func (Suite *PrefixMiddlewareSuite) TestNextFolder() {
+	prefix := prefixMiddleware{prefix: `/files/data`}
+	Suite.Equal(`files`, prefix.nextListFolder(`/`))
+	Suite.Equal(`files`, prefix.nextListFolder(``))
+	Suite.Equal(`data`, prefix.nextListFolder(`/files`))
+	Suite.Equal(`data`, prefix.nextListFolder(`files`))
+	Suite.Equal(`data`, prefix.nextListFolder(`files/`))
+
+	prefix = prefixMiddleware{prefix: `files/data`}
+	Suite.Equal(`files`, prefix.nextListFolder(`/`))
+	Suite.Equal(`files`, prefix.nextListFolder(``))
+	Suite.Equal(`data`, prefix.nextListFolder(`/files`))
+	Suite.Equal(`data`, prefix.nextListFolder(`files`))
+	Suite.Equal(`data`, prefix.nextListFolder(`files/`))
+}
+
+func (Suite *PrefixMiddlewareSuite) TestContainsPrefix() {
+	prefix := prefixMiddleware{prefix: `/`}
+	Suite.True(prefix.containsPrefix(`/data`))
+	Suite.True(prefix.containsPrefix(`/`))
+
+	prefix = prefixMiddleware{prefix: `/files`}
+	Suite.True(prefix.containsPrefix(`files`))
+}
+
+func (Suite *PrefixMiddlewareSuite) TestRemoveFolderPrefix() {
+	prefix := prefixMiddleware{prefix: `/`}
+	path, ok := prefix.removeFolderPrefix(`/files`)
+	Suite.Equal(`/files`, path)
+	Suite.True(ok)
+
+	prefix = prefixMiddleware{prefix: `/files`}
+	path, ok = prefix.removeFolderPrefix(`files`)
+	Suite.Equal(`/`, path)
+	Suite.True(ok)
+
+	path, ok = prefix.removeFolderPrefix(`/random`)
+	Suite.Equal(`/random`, path)
+	Suite.False(ok)
+}
+
+func TestFolderPrefixSuite(t *testing.T) {
+	suite.Run(t, new(PrefixMiddlewareSuite))
+}

+ 140 - 0
sftpd/mocks/middleware.go

@@ -0,0 +1,140 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ../middleware.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	io "io"
+	reflect "reflect"
+
+	gomock "github.com/golang/mock/gomock"
+	sftp "github.com/pkg/sftp"
+)
+
+// MockMiddleware is a mock of Middleware interface.
+type MockMiddleware struct {
+	ctrl     *gomock.Controller
+	recorder *MockMiddlewareMockRecorder
+}
+
+// MockMiddlewareMockRecorder is the mock recorder for MockMiddleware.
+type MockMiddlewareMockRecorder struct {
+	mock *MockMiddleware
+}
+
+// NewMockMiddleware creates a new mock instance.
+func NewMockMiddleware(ctrl *gomock.Controller) *MockMiddleware {
+	mock := &MockMiddleware{ctrl: ctrl}
+	mock.recorder = &MockMiddlewareMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockMiddleware) EXPECT() *MockMiddlewareMockRecorder {
+	return m.recorder
+}
+
+// Filecmd mocks base method.
+func (m *MockMiddleware) Filecmd(arg0 *sftp.Request) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Filecmd", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Filecmd indicates an expected call of Filecmd.
+func (mr *MockMiddlewareMockRecorder) Filecmd(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Filecmd", reflect.TypeOf((*MockMiddleware)(nil).Filecmd), arg0)
+}
+
+// Filelist mocks base method.
+func (m *MockMiddleware) Filelist(arg0 *sftp.Request) (sftp.ListerAt, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Filelist", arg0)
+	ret0, _ := ret[0].(sftp.ListerAt)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Filelist indicates an expected call of Filelist.
+func (mr *MockMiddlewareMockRecorder) Filelist(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Filelist", reflect.TypeOf((*MockMiddleware)(nil).Filelist), arg0)
+}
+
+// Fileread mocks base method.
+func (m *MockMiddleware) Fileread(arg0 *sftp.Request) (io.ReaderAt, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Fileread", arg0)
+	ret0, _ := ret[0].(io.ReaderAt)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Fileread indicates an expected call of Fileread.
+func (mr *MockMiddlewareMockRecorder) Fileread(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fileread", reflect.TypeOf((*MockMiddleware)(nil).Fileread), arg0)
+}
+
+// Filewrite mocks base method.
+func (m *MockMiddleware) Filewrite(arg0 *sftp.Request) (io.WriterAt, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Filewrite", arg0)
+	ret0, _ := ret[0].(io.WriterAt)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Filewrite indicates an expected call of Filewrite.
+func (mr *MockMiddlewareMockRecorder) Filewrite(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Filewrite", reflect.TypeOf((*MockMiddleware)(nil).Filewrite), arg0)
+}
+
+// Lstat mocks base method.
+func (m *MockMiddleware) Lstat(arg0 *sftp.Request) (sftp.ListerAt, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Lstat", arg0)
+	ret0, _ := ret[0].(sftp.ListerAt)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Lstat indicates an expected call of Lstat.
+func (mr *MockMiddlewareMockRecorder) Lstat(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lstat", reflect.TypeOf((*MockMiddleware)(nil).Lstat), arg0)
+}
+
+// OpenFile mocks base method.
+func (m *MockMiddleware) OpenFile(arg0 *sftp.Request) (sftp.WriterAtReaderAt, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "OpenFile", arg0)
+	ret0, _ := ret[0].(sftp.WriterAtReaderAt)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// OpenFile indicates an expected call of OpenFile.
+func (mr *MockMiddlewareMockRecorder) OpenFile(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenFile", reflect.TypeOf((*MockMiddleware)(nil).OpenFile), arg0)
+}
+
+// StatVFS mocks base method.
+func (m *MockMiddleware) StatVFS(arg0 *sftp.Request) (*sftp.StatVFS, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "StatVFS", arg0)
+	ret0, _ := ret[0].(*sftp.StatVFS)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// StatVFS indicates an expected call of StatVFS.
+func (mr *MockMiddlewareMockRecorder) StatVFS(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StatVFS", reflect.TypeOf((*MockMiddleware)(nil).StatVFS), arg0)
+}

+ 36 - 17
sftpd/server.go

@@ -8,6 +8,7 @@ import (
 	"io"
 	"net"
 	"os"
+	"path"
 	"path/filepath"
 	"runtime/debug"
 	"strings"
@@ -118,8 +119,10 @@ type Configuration struct {
 	KeyboardInteractiveHook string `json:"keyboard_interactive_auth_hook" mapstructure:"keyboard_interactive_auth_hook"`
 	// PasswordAuthentication specifies whether password authentication is allowed.
 	PasswordAuthentication bool `json:"password_authentication" mapstructure:"password_authentication"`
-	certChecker            *ssh.CertChecker
-	parsedUserCAKeys       []ssh.PublicKey
+	// Virtual root folder prefix to include in all file operations (ex: /files)
+	FolderPrefix     string `json:"folder_prefix" mapstructure:"folder_prefix"`
+	certChecker      *ssh.CertChecker
+	parsedUserCAKeys []ssh.PublicKey
 }
 
 type authenticationError struct {
@@ -198,6 +201,7 @@ func (c *Configuration) Initialize(configDir string) error {
 	c.configureKeyboardInteractiveAuth(serverConfig)
 	c.configureLoginBanner(serverConfig, configDir)
 	c.checkSSHCommands()
+	c.checkFolderPrefix()
 
 	exitChannel := make(chan error, 1)
 	serviceStatus.Bindings = nil
@@ -394,9 +398,9 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve
 
 	defer user.CloseFs() //nolint:errcheck
 
-	logger.Log(logger.LevelInfo, common.ProtocolSSH, connectionID,
-		"User id: %d, logged in with: %#v, username: %#v, home_dir: %#v remote addr: %#v",
-		user.ID, loginType, user.Username, user.HomeDir, ipAddr)
+	logger.Log(logger.LevelDebug, common.ProtocolSSH, connectionID,
+		"User %#v, logged in with: %#v, from ip: %#v, client version %#v",
+		user.Username, loginType, ipAddr, string(sconn.ClientVersion()))
 	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
 
 	sshConnection := common.NewSSHConnection(connectionID, conn)
@@ -475,11 +479,27 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	// Create a new handler for the currently logged in user's server.
-	handler := c.createHandler(connection)
+	var handlers sftp.Handlers
+
+	if c.FolderPrefix != "" {
+		prefixMiddleware := newPrefixMiddleware(c.FolderPrefix, connection)
+		handlers = sftp.Handlers{
+			FileGet:  prefixMiddleware,
+			FilePut:  prefixMiddleware,
+			FileCmd:  prefixMiddleware,
+			FileList: prefixMiddleware,
+		}
+	} else {
+		handlers = sftp.Handlers{
+			FileGet:  connection,
+			FilePut:  connection,
+			FileCmd:  connection,
+			FileList: connection,
+		}
+	}
 
 	// Create the server instance for the channel using the handler we created above.
-	server := sftp.NewRequestServer(channel, handler, sftp.WithRSAllocator())
+	server := sftp.NewRequestServer(channel, handlers, sftp.WithRSAllocator())
 
 	defer server.Close()
 	if err := server.Serve(); err == io.EOF {
@@ -492,15 +512,6 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co
 	}
 }
 
-func (c *Configuration) createHandler(connection *Connection) sftp.Handlers {
-	return sftp.Handlers{
-		FileGet:  connection,
-		FilePut:  connection,
-		FileCmd:  connection,
-		FileList: connection,
-	}
-}
-
 func checkAuthError(ip string, err error) {
 	if authErrors, ok := err.(*ssh.ServerAuthError); ok {
 		// check public key auth errors here
@@ -587,6 +598,14 @@ func (c *Configuration) checkSSHCommands() {
 		}
 	}
 	c.EnabledSSHCommands = sshCommands
+	logger.Debug(logSender, "", "enabled SSH commands %v", c.EnabledSSHCommands)
+}
+
+func (c *Configuration) checkFolderPrefix() {
+	if c.FolderPrefix != "" {
+		c.FolderPrefix = path.Join("/", c.FolderPrefix)
+		logger.Debug(logSender, "", "folder prefix %#v configured", c.FolderPrefix)
+	}
 }
 
 func (c *Configuration) generateDefaultHostKeys(configDir string) error {

+ 1 - 1
sftpd/ssh_cmd.go

@@ -85,7 +85,7 @@ func processSSHCommand(payload []byte, connection *Connection, enabledSSHCommand
 		}
 	}
 	err := connection.CloseFS()
-	connection.Log(logger.LevelDebug, "unable to unmarsh ssh command, close fs, err: %v", err)
+	connection.Log(logger.LevelDebug, "unable to unmarshal ssh command, close fs, err: %v", err)
 	return false
 }
 

+ 2 - 1
sftpgo.json

@@ -71,7 +71,8 @@
       "scp"
     ],
     "keyboard_interactive_auth_hook": "",
-    "password_authentication": true
+    "password_authentication": true,
+    "folder_prefix": ""
   },
   "ftpd": {
     "bindings": [