Skip to content

Commit

Permalink
Allow individual protocols to be enabled per user
Browse files Browse the repository at this point in the history
  • Loading branch information
drakkan committed Aug 17, 2020
1 parent fa53337 commit f322871
Show file tree
Hide file tree
Showing 18 changed files with 186 additions and 21 deletions.
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
18 changes: 14 additions & 4 deletions dataprovider/dataprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions dataprovider/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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{
Expand Down
4 changes: 4 additions & 0 deletions docs/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions examples/rest-api-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -76,6 +76,10 @@ Output:
"password",
"keyboard-interactive"
],
"denied_protocols": [
"DAV",
"FTP"
],
"file_extensions": [
{
"allowed_extensions": [
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 17 additions & 9 deletions examples/rest-api-cli/sftpgo_api_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def buildUserObject(self, user_id=0, username='', password='', public_keys=[], h
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,
Expand All @@ -102,7 +102,7 @@ def buildUserObject(self, user_id=0, username='', password='', public_keys=[], h
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,
Expand Down Expand Up @@ -154,7 +154,7 @@ def buildPermissions(self, root_perms, subdirs_perms):
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]:
Expand All @@ -171,6 +171,11 @@ def buildFilters(self, allowed_ip, denied_ip, denied_login_methods, denied_exten
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 = []
Expand Down Expand Up @@ -258,13 +263,13 @@ def addUser(self, username='', password='', public_keys='', home_dir='', uid=0,
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)

Expand All @@ -274,13 +279,14 @@ def updateUser(self, user_id, username='', password='', public_keys='', home_dir
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)

Expand Down Expand Up @@ -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: '
Expand Down Expand Up @@ -754,7 +762,7 @@ def addCommonUserArguments(parser):
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,
Expand All @@ -764,7 +772,7 @@ def addCommonUserArguments(parser):
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':
Expand Down
22 changes: 22 additions & 0 deletions ftpd/ftpd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions ftpd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions httpd/api_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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
}
Expand Down
Loading

0 comments on commit f322871

Please sign in to comment.