From 3ae8abda9ec85f95410281e8a64afba6813f8ecb Mon Sep 17 00:00:00 2001 From: mmcgeefeedo Date: Thu, 29 Jul 2021 00:32:55 +0200 Subject: [PATCH] sftpd: add folder prefix middleware --- config/config.go | 2 + go.mod | 1 + go.sum | 1 + sftpd/middleware.go | 229 ++++++++++++++++++++++++++ sftpd/middleware_test.go | 328 ++++++++++++++++++++++++++++++++++++++ sftpd/mocks/middleware.go | 140 ++++++++++++++++ sftpd/server.go | 53 ++++-- sftpd/ssh_cmd.go | 2 +- sftpgo.json | 3 +- 9 files changed, 740 insertions(+), 19 deletions(-) create mode 100644 sftpd/middleware.go create mode 100644 sftpd/middleware_test.go create mode 100644 sftpd/mocks/middleware.go diff --git a/config/config.go b/config/config.go index 9ec24081..e607940c 100644 --- a/config/config.go +++ b/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) diff --git a/go.mod b/go.mod index 61b17a68..140ce12f 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index fe7e8259..21664e77 100644 --- a/go.sum +++ b/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= diff --git a/sftpd/middleware.go b/sftpd/middleware.go new file mode 100644 index 00000000..16041196 --- /dev/null +++ b/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 +} diff --git a/sftpd/middleware_test.go b/sftpd/middleware_test.go new file mode 100644 index 00000000..ec73af65 --- /dev/null +++ b/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)) +} diff --git a/sftpd/mocks/middleware.go b/sftpd/mocks/middleware.go new file mode 100644 index 00000000..4a5a5428 --- /dev/null +++ b/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) +} diff --git a/sftpd/server.go b/sftpd/server.go index 31b4e50b..764f2894 100644 --- a/sftpd/server.go +++ b/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 { diff --git a/sftpd/ssh_cmd.go b/sftpd/ssh_cmd.go index 29e0c193..73175310 100644 --- a/sftpd/ssh_cmd.go +++ b/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 } diff --git a/sftpgo.json b/sftpgo.json index 0f2443df..306f5d0c 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -71,7 +71,8 @@ "scp" ], "keyboard_interactive_auth_hook": "", - "password_authentication": true + "password_authentication": true, + "folder_prefix": "" }, "ftpd": { "bindings": [