diff --git a/README.md b/README.md index f7293bf4..89277f34 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,10 @@ It can serve local filesystem, S3 or Google Cloud Storage. - Atomic uploads are configurable. - Support for Git repositories over SSH. - SCP and rsync are supported. -- FTP/S is supported. -- WebDAV is supported. +- FTP/S is supported. You can configure the FTP service to require TLS for both control and data connections. +- [WebDAV](./docs/webdav.md) is supported. - Support for serving local filesystem, S3 Compatible Object Storage and Google Cloud Storage over SFTP/SCP/FTP/WebDAV. +- Per user protocols restrictions. You can configure the allowed protocols (SSH/FTP/WebDAV) for each user. - [Prometheus metrics](./docs/metrics.md) are exposed. - Support for HAProxy PROXY protocol: you can proxy and/or load balance the SFTP/SCP/FTP/WebDAV service without losing the information about the client's address. - [REST API](./docs/rest-api.md) for users and folders management, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection. @@ -139,15 +140,19 @@ More information about custom actions can be found [here](./docs/custom-actions. Directories outside the user home directory can be exposed as virtual folders, more information [here](./docs/virtual-folders.md). +## Other hooks + +You can get notified as soon as a new connection is established using the [Post-connect hook](./docs/post-connect-hook.md) and after each login using the [Post-login hook](./docs/post-login-hook.md). + ## Storage backends ### S3 Compabible Object Storage backends -Each user can be mapped to whole bucket or to a bucket virtual folder. This way, the mapped bucket/virtual folder is exposed over SFTP/SCP. More information about S3 integration can be found [here](./docs/s3.md). +Each user can be mapped to the whole bucket or to a bucket virtual folder. This way, the mapped bucket/virtual folder is exposed over SFTP/SCP/FTP/WebDAV. More information about S3 integration can be found [here](./docs/s3.md). ### Google Cloud Storage backend -Each user can be mapped with a Google Cloud Storage bucket or a bucket virtual folder. This way, the mapped bucket/virtual folder is exposed over SFTP/SCP. More information about Google Cloud Storage integration can be found [here](./docs/google-cloud-storage.md). +Each user can be mapped with a Google Cloud Storage bucket or a bucket virtual folder. This way, the mapped bucket/virtual folder is exposed over SFTP/SCP/FTP/WebDAV. More information about Google Cloud Storage integration can be found [here](./docs/google-cloud-storage.md). ### Other Storage backends diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 941a748c..47d94e41 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -93,7 +93,9 @@ var ( // SSHMultiStepsLoginMethods defines the supported Multi-Step Authentications SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt} // ErrNoAuthTryed defines the error for connection closed before authentication - ErrNoAuthTryed = errors.New("no auth tryed") + ErrNoAuthTryed = errors.New("no auth tryed") + // ValidProtocols defines all the valid protcols + ValidProtocols = []string{"SSH", "FTP", "DAV"} config Config provider Provider sqlPlaceholders []string @@ -853,6 +855,9 @@ func validateFilters(user *User) error { if len(user.Filters.DeniedLoginMethods) == 0 { user.Filters.DeniedLoginMethods = []string{} } + if len(user.Filters.DeniedProtocols) == 0 { + user.Filters.DeniedProtocols = []string{} + } for _, IPMask := range user.Filters.DeniedIP { _, _, err := net.ParseCIDR(IPMask) if err != nil { @@ -873,10 +878,15 @@ func validateFilters(user *User) error { return &ValidationError{err: fmt.Sprintf("invalid login method: %#v", loginMethod)} } } - if err := validateFiltersFileExtensions(user); err != nil { - return err + if len(user.Filters.DeniedProtocols) >= len(ValidProtocols) { + return &ValidationError{err: "invalid denied_protocols"} } - return nil + for _, p := range user.Filters.DeniedProtocols { + if !utils.IsStringInSlice(p, ValidProtocols) { + return &ValidationError{err: fmt.Sprintf("invalid protocol: %#v", p)} + } + } + return validateFiltersFileExtensions(user) } func saveGCSCredentials(user *User) error { diff --git a/dataprovider/user.go b/dataprovider/user.go index 2e3e62f0..f068eab3 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -94,6 +94,9 @@ type UserFilters struct { // these login methods are not allowed. // If null or empty any available login method is allowed DeniedLoginMethods []string `json:"denied_login_methods,omitempty"` + // these protocols are not allowed. + // If null or empty any available protocol is allowed + DeniedProtocols []string `json:"denied_protocols,omitempty"` // filters based on file extensions. // Please note that these restrictions can be easily bypassed. FileExtensions []ExtensionsFilter `json:"file_extensions,omitempty"` @@ -675,6 +678,8 @@ func (u *User) getACopy() User { copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods) filters.FileExtensions = make([]ExtensionsFilter, len(u.Filters.FileExtensions)) copy(filters.FileExtensions, u.Filters.FileExtensions) + filters.DeniedProtocols = make([]string, len(u.Filters.DeniedProtocols)) + copy(filters.DeniedProtocols, u.Filters.DeniedProtocols) fsConfig := Filesystem{ Provider: u.FsConfig.Provider, S3Config: vfs.S3FsConfig{ diff --git a/docs/account.md b/docs/account.md index 973d87a1..9e3adaa9 100644 --- a/docs/account.md +++ b/docs/account.md @@ -37,6 +37,10 @@ For each account, the following properties can be configured: - `keyboard-interactive` - `publickey+password` - `publickey+keyboard-interactive` +- `denied_protocols`, list of protocols not allowed. The following protocols are supported: + - `SSH` + - `FTP` + - `DAV` - `file_extensions`, list of struct. These restrictions do not apply to files listing for performance reasons, so a denied file cannot be downloaded/overwritten/renamed but it will still be listed in the list of files. Please note that these restrictions can be easily bypassed. Each struct contains the following fields: - `allowed_extensions`, list of, case insensitive, allowed files extension. Shell like expansion is not supported so you have to specify `.jpg` and not `*.jpg`. Any file that does not end with this suffix will be denied - `denied_extensions`, list of, case insensitive, denied files extension. Denied file extensions are evaluated before the allowed ones diff --git a/examples/rest-api-cli/README.md b/examples/rest-api-cli/README.md index ce2b44e0..66ed4880 100644 --- a/examples/rest-api-cli/README.md +++ b/examples/rest-api-cli/README.md @@ -44,7 +44,7 @@ Let's see a sample usage for each REST API. Command: ```console -python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1::list,download" "/dir2::*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --s3-upload-part-size 10 --s3-upload-concurrency 4 --denied-login-methods "password" "keyboard-interactive" --allowed-extensions "/dir1::.jpg,.png" "/dir2::.rar,.png" --denied-extensions "/dir3::.zip,.rar" +python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1::list,download" "/dir2::*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --s3-upload-part-size 10 --s3-upload-concurrency 4 --denied-login-methods "password" "keyboard-interactive" --allowed-extensions "/dir1::.jpg,.png" "/dir2::.rar,.png" --denied-extensions "/dir3::.zip,.rar" --denied-protocols DAV FTP ``` Output: @@ -76,6 +76,10 @@ Output: "password", "keyboard-interactive" ], + "denied_protocols": [ + "DAV", + "FTP" + ], "file_extensions": [ { "allowed_extensions": [ @@ -140,7 +144,7 @@ Output: Command: ```console -python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1::list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1::-1::-1" "/vdir2::/tmp/mapped2::100::104857600" --allowed-extensions "" --denied-extensions "" --max-upload-file-size 104857600 +python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1::list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1::-1::-1" "/vdir2::/tmp/mapped2::100::104857600" --allowed-extensions "" --denied-extensions "" --max-upload-file-size 104857600 --denied-protocols "" ``` Output: diff --git a/examples/rest-api-cli/sftpgo_api_cli.py b/examples/rest-api-cli/sftpgo_api_cli.py index 1e5dd349..44e02ba8 100755 --- a/examples/rest-api-cli/sftpgo_api_cli.py +++ b/examples/rest-api-cli/sftpgo_api_cli.py @@ -82,7 +82,7 @@ class SFTPGoApiRequests: s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_extensions=[], allowed_extensions=[], s3_upload_part_size=0, s3_upload_concurrency=0, - max_upload_file_size=0): + max_upload_file_size=0, denied_protocols=[]): user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid, 'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files, 'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth, @@ -102,7 +102,7 @@ class SFTPGoApiRequests: user.update({'virtual_folders':self.buildVirtualFolders(virtual_folders)}) user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods, denied_extensions, - allowed_extensions, max_upload_file_size)}) + allowed_extensions, max_upload_file_size, denied_protocols)}) user.update({'filesystem':self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, gcs_credentials_file, @@ -154,7 +154,7 @@ class SFTPGoApiRequests: return permissions def buildFilters(self, allowed_ip, denied_ip, denied_login_methods, denied_extensions, allowed_extensions, - max_upload_file_size): + max_upload_file_size, denied_protocols): filters = {"max_upload_file_size":max_upload_file_size} if allowed_ip: if len(allowed_ip) == 1 and not allowed_ip[0]: @@ -171,6 +171,11 @@ class SFTPGoApiRequests: filters.update({'denied_login_methods':[]}) else: filters.update({'denied_login_methods':denied_login_methods}) + if denied_protocols: + if len(denied_protocols) == 1 and not denied_protocols[0]: + filters.update({'denied_protocols':[]}) + else: + filters.update({'denied_protocols':denied_protocols}) extensions_filter = [] extensions_denied = [] extensions_allowed = [] @@ -258,13 +263,13 @@ class SFTPGoApiRequests: s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_extensions=[], allowed_extensions=[], - s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0): + s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, denied_protocols=[]): u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions, quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_extensions, - allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size) + allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols) r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -274,13 +279,14 @@ class SFTPGoApiRequests: s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_extensions=[], - allowed_extensions=[], s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0): + allowed_extensions=[], s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, + denied_protocols=[]): u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions, quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_extensions, - allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size) + allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols) r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -558,6 +564,8 @@ def addCommonUserArguments(parser): parser.add_argument('-L', '--denied-login-methods', type=str, nargs='+', default=[], choices=['', 'publickey', 'password', 'keyboard-interactive', 'publickey+password', 'publickey+keyboard-interactive'], help='Default: %(default)s') + parser.add_argument('--denied-protocols', type=str, nargs='+', default=[], + choices=['', 'SSH', 'FTP', 'DAV'], help='Default: %(default)s') parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. ' +'For example: "/somedir::list,download" "/otherdir/subdir::*" Default: %(default)s') parser.add_argument('--virtual-folders', type=str, nargs='*', default=[], help='Virtual folder mapping. For example: ' @@ -754,7 +762,7 @@ if __name__ == '__main__': args.s3_endpoint, args.s3_storage_class, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods, args.virtual_folders, args.denied_extensions, args.allowed_extensions, - args.s3_upload_part_size, args.s3_upload_concurrency, args.max_upload_file_size) + args.s3_upload_part_size, args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols) elif args.command == 'update-user': api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, @@ -764,7 +772,7 @@ if __name__ == '__main__': args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods, args.virtual_folders, args.denied_extensions, args.allowed_extensions, args.s3_upload_part_size, - args.s3_upload_concurrency, args.max_upload_file_size) + args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols) elif args.command == 'delete-user': api.deleteUser(args.id) elif args.command == 'get-users': diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index edc618eb..65fb5719 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -676,6 +676,28 @@ func TestResume(t *testing.T) { assert.NoError(t, err) } +func TestDeniedProtocols(t *testing.T) { + u := getTestUser() + u.Filters.DeniedProtocols = []string{common.ProtocolFTP} + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + _, err = getFTPClient(user, false) + assert.Error(t, err) + user.Filters.DeniedProtocols = []string{common.ProtocolSSH, common.ProtocolWebDAV} + user, _, err = httpd.UpdateUser(user, http.StatusOK) + assert.NoError(t, err) + client, err := getFTPClient(user, true) + if assert.NoError(t, err) { + assert.NoError(t, checkBasicFTP(client)) + err = client.Quit() + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestQuotaLimits(t *testing.T) { u := getTestUser() u.QuotaFiles = 1 diff --git a/ftpd/server.go b/ftpd/server.go index d3d9487f..29efa79e 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -157,6 +157,10 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext user.Username, user.HomeDir) return nil, fmt.Errorf("cannot login user with invalid home dir: %#v", user.HomeDir) } + if utils.IsStringInSlice(common.ProtocolFTP, user.Filters.DeniedProtocols) { + logger.Debug(logSender, connectionID, "cannot login user %#v, protocol FTP is not allowed", user.Username) + return nil, fmt.Errorf("Protocol FTP is not allowed for user %#v", user.Username) + } if user.MaxSessions > 0 { activeSessions := common.Connections.GetActiveSessions(user.Username) if activeSessions >= user.MaxSessions { diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 9693d457..f9a369ab 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -708,6 +708,9 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) if len(expected.Filters.DeniedLoginMethods) != len(actual.Filters.DeniedLoginMethods) { return errors.New("Denied login methods mismatch") } + if len(expected.Filters.DeniedProtocols) != len(actual.Filters.DeniedProtocols) { + return errors.New("Denied protocols mismatch") + } if expected.Filters.MaxUploadFileSize != actual.Filters.MaxUploadFileSize { return errors.New("Max upload file size mismatch") } @@ -726,6 +729,11 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) return errors.New("Denied login methods contents mismatch") } } + for _, protocol := range expected.Filters.DeniedProtocols { + if !utils.IsStringInSlice(protocol, actual.Filters.DeniedProtocols) { + return errors.New("Denied protocols contents mismatch") + } + } if err := compareUserFileExtensionsFilters(expected, actual); err != nil { return err } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 5d69fe43..8727c2e7 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -352,6 +352,11 @@ func TestAddUserInvalidFilters(t *testing.T) { } _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) + u.Filters.FileExtensions = nil + u.Filters.DeniedProtocols = []string{"invalid"} + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + u.Filters.DeniedProtocols = dataprovider.ValidProtocols + _, _, err = httpd.AddUser(u, http.StatusBadRequest) } func TestAddUserInvalidFsConfig(t *testing.T) { @@ -623,6 +628,7 @@ func TestUpdateUser(t *testing.T) { user.Filters.AllowedIP = []string{"192.168.1.0/24", "192.168.2.0/24"} user.Filters.DeniedIP = []string{"192.168.3.0/24", "192.168.4.0/24"} user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} + user.Filters.DeniedProtocols = []string{common.ProtocolWebDAV} user.Filters.FileExtensions = append(user.Filters.FileExtensions, dataprovider.ExtensionsFilter{ Path: "/subdir", AllowedExtensions: []string{".zip", ".rar"}, @@ -2420,6 +2426,7 @@ func TestWebUserUpdateMock(t *testing.T) { form.Set("denied_ip", " 10.0.0.2/32 ") form.Set("denied_extensions", "/dir1::.zip") form.Set("ssh_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive) + form.Set("denied_protocols", common.ProtocolFTP) form.Set("max_upload_file_size", "100") b, contentType, _ := getMultipartFormData(form, "", "") req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b) @@ -2451,7 +2458,7 @@ func TestWebUserUpdateMock(t *testing.T) { assert.True(t, utils.IsStringInSlice("192.168.1.3/32", updateUser.Filters.AllowedIP)) assert.True(t, utils.IsStringInSlice("10.0.0.2/32", updateUser.Filters.DeniedIP)) assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, updateUser.Filters.DeniedLoginMethods)) - assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, updateUser.Filters.DeniedLoginMethods)) + assert.True(t, utils.IsStringInSlice(common.ProtocolFTP, updateUser.Filters.DeniedProtocols)) assert.True(t, utils.IsStringInSlice(".zip", updateUser.Filters.FileExtensions[0].DeniedExtensions)) req, err = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) assert.NoError(t, err) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index b59d4503..edccf163 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -170,6 +170,14 @@ func TestCompareUserFilters(t *testing.T) { assert.Error(t, err) expected.Filters.DeniedLoginMethods = []string{} actual.Filters.DeniedLoginMethods = []string{} + actual.Filters.DeniedProtocols = []string{common.ProtocolFTP} + err = checkUser(expected, actual) + assert.Error(t, err) + expected.Filters.DeniedProtocols = []string{common.ProtocolWebDAV} + err = checkUser(expected, actual) + assert.Error(t, err) + expected.Filters.DeniedProtocols = []string{} + actual.Filters.DeniedProtocols = []string{} expected.Filters.MaxUploadFileSize = 0 actual.Filters.MaxUploadFileSize = 100 err = checkUser(expected, actual) diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 8d77d293..79d796e1 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.1 info: title: SFTPGo description: 'SFTPGo REST API' - version: 1.9.4 + version: 1.9.5 servers: - url: /api/v1 @@ -1479,6 +1479,12 @@ components: - 'keyboard-interactive' - 'publickey+password' - 'publickey+keyboard-interactive' + SupportedProtocols: + type: string + enum: + - 'SSH' + - 'FTP' + - 'DAV' ExtensionsFilter: type: object properties: @@ -1522,6 +1528,12 @@ components: $ref: '#/components/schemas/LoginMethods' nullable: true description: if null or empty any available login method is allowed + denied_protocols: + type: array + items: + $ref: '#/components/schemas/SupportedProtocols' + nullable: true + description: if null or empty any available protocol is allowed file_extensions: type: array items: diff --git a/httpd/web.go b/httpd/web.go index c557b1f8..9d078ba8 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -88,6 +88,7 @@ type userPage struct { Error string ValidPerms []string ValidSSHLoginMethods []string + ValidProtocols []string RootDirPerms []string } @@ -208,6 +209,7 @@ func renderAddUserPage(w http.ResponseWriter, user dataprovider.User, error stri User: user, ValidPerms: dataprovider.ValidPerms, ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, + ValidProtocols: dataprovider.ValidProtocols, RootDirPerms: user.GetPermissionsForPath("/"), } renderTemplate(w, templateUser, data) @@ -221,6 +223,7 @@ func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error s User: user, ValidPerms: dataprovider.ValidPerms, ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, + ValidProtocols: dataprovider.ValidProtocols, RootDirPerms: user.GetPermissionsForPath("/"), } renderTemplate(w, templateUser, data) @@ -345,6 +348,7 @@ func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters { filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",") filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",") filters.DeniedLoginMethods = r.Form["ssh_login_methods"] + filters.DeniedProtocols = r.Form["denied_protocols"] allowedExtensions := getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), 1) deniedExtensions := getFileExtensionsFromPostField(r.Form.Get("denied_extensions"), 2) extensions := []dataprovider.ExtensionsFilter{} diff --git a/sftpd/server.go b/sftpd/server.go index 48d8f37b..50412fdb 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -393,6 +393,10 @@ func loginUser(user dataprovider.User, loginMethod, publicKey string, conn ssh.C user.Username, user.HomeDir) return nil, fmt.Errorf("cannot login user with invalid home dir: %#v", user.HomeDir) } + if utils.IsStringInSlice(common.ProtocolSSH, user.Filters.DeniedProtocols) { + logger.Debug(logSender, connectionID, "cannot login user %#v, protocol SSH is not allowed", user.Username) + return nil, fmt.Errorf("Protocol SSH is not allowed for user %#v", user.Username) + } if user.MaxSessions > 0 { activeSessions := common.Connections.GetActiveSessions(user.Username) if activeSessions >= user.MaxSessions { diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 30822a89..d856d2e3 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -1157,6 +1157,30 @@ func TestLoginInvalidFs(t *testing.T) { assert.NoError(t, err) } +func TestDeniedProtocols(t *testing.T) { + u := getTestUser(true) + u.Filters.DeniedProtocols = []string{common.ProtocolSSH} + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, true) + if !assert.Error(t, err, "SSH protocol is disabled, authentication must fail") { + client.Close() + } + user.Filters.DeniedProtocols = []string{common.ProtocolFTP, common.ProtocolWebDAV} + user, _, err = httpd.UpdateUser(user, http.StatusOK) + assert.NoError(t, err) + client, err = getSftpClient(user, true) + if assert.NoError(t, err) { + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestDeniedLoginMethods(t *testing.T) { u := getTestUser(true) u.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.LoginMethodPassword} diff --git a/templates/user.html b/templates/user.html index a7a8d803..fd785fd5 100644 --- a/templates/user.html +++ b/templates/user.html @@ -70,6 +70,19 @@ +
+ +
+ +
+
+
diff --git a/webdavd/server.go b/webdavd/server.go index 6751ae3e..1dfe2eeb 100644 --- a/webdavd/server.go +++ b/webdavd/server.go @@ -180,6 +180,10 @@ func (s *webDavServer) validateUser(user dataprovider.User, r *http.Request) (st user.Username, user.HomeDir) return connID, fmt.Errorf("cannot login user with invalid home dir: %#v", user.HomeDir) } + if utils.IsStringInSlice(common.ProtocolWebDAV, user.Filters.DeniedProtocols) { + logger.Debug(logSender, connectionID, "cannot login user %#v, protocol DAV is not allowed", user.Username) + return connID, fmt.Errorf("Protocol DAV is not allowed for user %#v", user.Username) + } if user.MaxSessions > 0 { activeSessions := common.Connections.GetActiveSessions(user.Username) if activeSessions >= user.MaxSessions { diff --git a/webdavd/webdavd_test.go b/webdavd/webdavd_test.go index 89c051a3..40fcab45 100644 --- a/webdavd/webdavd_test.go +++ b/webdavd/webdavd_test.go @@ -566,6 +566,25 @@ func TestUploadErrors(t *testing.T) { assert.NoError(t, err) } +func TestDeniedProtocols(t *testing.T) { + u := getTestUser() + u.Filters.DeniedProtocols = []string{common.ProtocolWebDAV} + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client := getWebDavClient(user) + assert.Error(t, checkBasicFunc(client)) + + user.Filters.DeniedProtocols = []string{common.ProtocolSSH, common.ProtocolFTP} + user, _, err = httpd.UpdateUser(user, http.StatusOK) + client = getWebDavClient(user) + assert.NoError(t, checkBasicFunc(client)) + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestQuotaLimits(t *testing.T) { u := getTestUser() u.QuotaFiles = 1