Browse Source

Allow individual protocols to be enabled per user

Fixes #154
Nicola Murino 5 years ago
parent
commit
f3228713bc

+ 9 - 4
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
 

+ 14 - 4
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 {

+ 5 - 0
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{

+ 4 - 0
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

+ 6 - 2
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:

+ 17 - 9
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':

+ 22 - 0
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

+ 4 - 0
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 {

+ 8 - 0
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
 	}

+ 8 - 1
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)

+ 8 - 0
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)

+ 13 - 1
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:

+ 4 - 0
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{}

+ 4 - 0
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 {

+ 24 - 0
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}

+ 13 - 0
templates/user.html

@@ -70,6 +70,19 @@
         </div>
     </div>
 
+    <div class="form-group row">
+        <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
+        <div class="col-sm-10">
+            <select class="form-control" id="idProtocols" name="denied_protocols" multiple>
+                {{range $protocol := .ValidProtocols}}
+                <option value="{{$protocol}}"
+                    {{range $p := $.User.Filters.DeniedProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
+                </option>
+                {{end}}
+            </select>
+        </div>
+    </div>
+
     <div class="form-group row">
         <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
         <div class="col-sm-10">

+ 4 - 0
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 {

+ 19 - 0
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