Forráskód Böngészése

filters: we can now set allowed and denied files extensions

Nicola Murino 5 éve
szülő
commit
b885d453a2

+ 7 - 2
README.md

@@ -19,6 +19,7 @@ Full featured and highly configurable SFTP server
 - Per user and per directory permission management: list directory contents, upload, overwrite, download, delete, rename, create directories, create symlinks, change owner/group and mode, change access and modification times.
 - Per user and per directory permission management: list directory contents, upload, overwrite, download, delete, rename, create directories, create symlinks, change owner/group and mode, change access and modification times.
 - Per user files/folders ownership mapping: you can map all the users to the system account that runs SFTPGo (all platforms are supported) or you can run SFTPGo as root user and map each user or group of users to a different system account (*NIX only).
 - Per user files/folders ownership mapping: you can map all the users to the system account that runs SFTPGo (all platforms are supported) or you can run SFTPGo as root user and map each user or group of users to a different system account (*NIX only).
 - Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address.
 - Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address.
+- Per user and per directory file extensions filters are supported: files can be allowed or denied based on their extensions.
 - Virtual folders are supported: directories outside the user home directory can be exposed as virtual folders.
 - Virtual folders are supported: directories outside the user home directory can be exposed as virtual folders.
 - Configurable custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
 - Configurable custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
 - Automatically terminating idle connections.
 - Automatically terminating idle connections.
@@ -157,8 +158,8 @@ The `sftpgo` configuration file contains the following sections:
       - `scp`, SCP is an experimental feature, we have our own SCP implementation since we can't rely on "scp" system command to proper handle quotas and user's home dir restrictions. The SCP protocol is quite simple but there is no official docs about it, so we need more testing and feedbacks before enabling it by default. We may not handle some borderline cases or have sneaky bugs. Please do accurate tests yourself before enabling SCP and let us known if something does not work as expected for your use cases. SCP between two remote hosts is supported using the `-3` scp option.
       - `scp`, SCP is an experimental feature, we have our own SCP implementation since we can't rely on "scp" system command to proper handle quotas and user's home dir restrictions. The SCP protocol is quite simple but there is no official docs about it, so we need more testing and feedbacks before enabling it by default. We may not handle some borderline cases or have sneaky bugs. Please do accurate tests yourself before enabling SCP and let us known if something does not work as expected for your use cases. SCP between two remote hosts is supported using the `-3` scp option.
       - `md5sum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. Useful to check message digests for uploaded files. These commands are implemented inside SFTPGo so they work even if the matching system commands are not available, for example on Windows.
       - `md5sum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. Useful to check message digests for uploaded files. These commands are implemented inside SFTPGo so they work even if the matching system commands are not available, for example on Windows.
       - `cd`, `pwd`. Some SFTP clients does not support the SFTP SSH_FXP_REALPATH packet type and so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` do nothing and `pwd` always returns the `/` path.
       - `cd`, `pwd`. Some SFTP clients does not support the SFTP SSH_FXP_REALPATH packet type and so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` do nothing and `pwd` always returns the `/` path.
-      - `git-receive-pack`, `git-upload-pack`, `git-upload-archive`. These commands enable support for Git repositories over SSH, they need to be installed and in your system's `PATH`. Git commands are not allowed inside virtual folders.
-      - `rsync`. The `rsync` command need to be installed and in your system's `PATH`. We cannot avoid that rsync create symlinks so if the user has the permission to create symlinks we add the option `--safe-links` to the received rsync command if it is not already set. This should prevent to create symlinks that point outside the home dir. If the user cannot create symlinks we add the option `--munge-links`, if it is not already set. This should make symlinks unusable (but manually recoverable). The `rsync` command interacts with the filesystem directly and it is not aware about virtual folders, so it will be automatically disabled for users with virtual folders.
+      - `git-receive-pack`, `git-upload-pack`, `git-upload-archive`. These commands enable support for Git repositories over SSH, they need to be installed and in your system's `PATH`. Git commands are not allowed inside virtual folders and inside directories with file extensions filters.
+      - `rsync`. The `rsync` command need to be installed and in your system's `PATH`. We cannot avoid that rsync create symlinks so if the user has the permission to create symlinks we add the option `--safe-links` to the received rsync command if it is not already set. This should prevent to create symlinks that point outside the home dir. If the user cannot create symlinks we add the option `--munge-links`, if it is not already set. This should make symlinks unusable (but manually recoverable). The `rsync` command interacts with the filesystem directly and it is not aware about virtual folders and file extensions filters, so it will be automatically disabled for users with virtual folders or file extensions filters.
     - `keyboard_interactive_auth_program`, string. Absolute path to an external program to use for keyboard interactive authentication. See the "Keyboard Interactive Authentication" paragraph for more details.
     - `keyboard_interactive_auth_program`, string. Absolute path to an external program to use for keyboard interactive authentication. See the "Keyboard Interactive Authentication" paragraph for more details.
     - `proxy_protocol`, integer. Support for [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt). If you are running SFTPGo behind a proxy server such as HAProxy, AWS ELB or NGNIX, you can enable the proxy protocol. It provides a convenient way to safely transport connection information such as a client's address across multiple layers of NAT or TCP proxies to get the real client IP address instead of the proxy IP. Both protocol v1 and v2 are supported. If the proxy protocol is enabled in SFTPGo then you have to enable the protocol in your proxy configuration too, for example for HAProxy add `send-proxy` or `send-proxy-v2` to each server configuration line. The following modes are supported:
     - `proxy_protocol`, integer. Support for [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt). If you are running SFTPGo behind a proxy server such as HAProxy, AWS ELB or NGNIX, you can enable the proxy protocol. It provides a convenient way to safely transport connection information such as a client's address across multiple layers of NAT or TCP proxies to get the real client IP address instead of the proxy IP. Both protocol v1 and v2 are supported. If the proxy protocol is enabled in SFTPGo then you have to enable the protocol in your proxy configuration too, for example for HAProxy add `send-proxy` or `send-proxy-v2` to each server configuration line. The following modes are supported:
       - 0, disabled
       - 0, disabled
@@ -742,6 +743,10 @@ For each account the following properties can be configured:
   - `publickey`
   - `publickey`
   - `password`
   - `password`
   - `keyboard-interactive`
   - `keyboard-interactive`
+- `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
+  - `path`, SFTP/SCP path, if no other specific filter is defined, the filter apply for sub directories too. For example if filters are defined for the paths `/` and `/sub` then the filters for `/` are applied for any file outside the `/sub` directory
 - `fs_provider`, filesystem to serve via SFTP. Local filesystem and S3 Compatible Object Storage are supported
 - `fs_provider`, filesystem to serve via SFTP. Local filesystem and S3 Compatible Object Storage are supported
 - `s3_bucket`, required for S3 filesystem
 - `s3_bucket`, required for S3 filesystem
 - `s3_region`, required for S3 filesystem. Must match the region for your bucket. You can find here the list of available [AWS regions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions). For example if your bucket is at `Frankfurt` you have to set the region to `eu-central-1`
 - `s3_region`, required for S3 filesystem. Must match the region for your bucket. You can find here the list of available [AWS regions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions). For example if your bucket is at `Frankfurt` you have to set the region to `eu-central-1`

+ 29 - 0
dataprovider/dataprovider.go

@@ -677,6 +677,32 @@ func validatePublicKeys(user *User) error {
 	return nil
 	return nil
 }
 }
 
 
+func validateFiltersFileExtensions(user *User) error {
+	if len(user.Filters.FileExtensions) == 0 {
+		user.Filters.FileExtensions = []ExtensionsFilter{}
+		return nil
+	}
+	filteredPaths := []string{}
+	var filters []ExtensionsFilter
+	for _, f := range user.Filters.FileExtensions {
+		cleanedPath := filepath.ToSlash(path.Clean(f.Path))
+		if !path.IsAbs(cleanedPath) {
+			return &ValidationError{err: fmt.Sprintf("invalid path %#v for file extensions filter", f.Path)}
+		}
+		if utils.IsStringInSlice(cleanedPath, filteredPaths) {
+			return &ValidationError{err: fmt.Sprintf("duplicate file extensions filter for path %#v", f.Path)}
+		}
+		if len(f.AllowedExtensions) == 0 && len(f.DeniedExtensions) == 0 {
+			return &ValidationError{err: fmt.Sprintf("empty file extensions filter for path %#v", f.Path)}
+		}
+		f.Path = cleanedPath
+		filters = append(filters, f)
+		filteredPaths = append(filteredPaths, cleanedPath)
+	}
+	user.Filters.FileExtensions = filters
+	return nil
+}
+
 func validateFilters(user *User) error {
 func validateFilters(user *User) error {
 	if len(user.Filters.AllowedIP) == 0 {
 	if len(user.Filters.AllowedIP) == 0 {
 		user.Filters.AllowedIP = []string{}
 		user.Filters.AllowedIP = []string{}
@@ -707,6 +733,9 @@ func validateFilters(user *User) error {
 			return &ValidationError{err: fmt.Sprintf("invalid login method: %#v", loginMethod)}
 			return &ValidationError{err: fmt.Sprintf("invalid login method: %#v", loginMethod)}
 		}
 		}
 	}
 	}
+	if err := validateFiltersFileExtensions(user); err != nil {
+		return err
+	}
 	return nil
 	return nil
 }
 }
 
 

+ 64 - 0
dataprovider/user.go

@@ -8,6 +8,7 @@ import (
 	"path"
 	"path"
 	"path/filepath"
 	"path/filepath"
 	"strconv"
 	"strconv"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
@@ -51,6 +52,29 @@ const (
 	SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
 	SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
 )
 )
 
 
+// ExtensionsFilter defines filters based on file extensions.
+// These restrictions do not apply to files listing for performance reasons, so
+// a denied file cannot be downloaded/overwritten/renamed but will still be
+// it will still be listed in the list of files.
+// System commands such as Git and rsync interacts with the filesystem directly
+// and they are not aware about these restrictions so rsync is not allowed if
+// extensions filters are defined and Git is not allowed inside a path with
+// extensions filters
+type ExtensionsFilter struct {
+	// SFTP/SCP path, if no other specific filter is defined, the filter apply for
+	// sub directories too.
+	// For example if filters are defined for the paths "/" and "/sub" then the
+	// filters for "/" are applied for any file outside the "/sub" directory
+	Path string `json:"path"`
+	// only files with these, case insensitive, extensions are allowed.
+	// Shell like expansion is not supported so you have to specify ".jpg" and
+	// not "*.jpg"
+	AllowedExtensions []string `json:"allowed_extensions,omitempty"`
+	// files with these, case insensitive, extensions are not allowed.
+	// Denied file extensions are evaluated before the allowed ones
+	DeniedExtensions []string `json:"denied_extensions,omitempty"`
+}
+
 // UserFilters defines additional restrictions for a user
 // UserFilters defines additional restrictions for a user
 type UserFilters struct {
 type UserFilters struct {
 	// only clients connecting from these IP/Mask are allowed.
 	// only clients connecting from these IP/Mask are allowed.
@@ -63,6 +87,9 @@ type UserFilters struct {
 	// these login methods are not allowed.
 	// these login methods are not allowed.
 	// If null or empty any available login method is allowed
 	// If null or empty any available login method is allowed
 	DeniedLoginMethods []string `json:"denied_login_methods,omitempty"`
 	DeniedLoginMethods []string `json:"denied_login_methods,omitempty"`
+	// filters based on file extensions.
+	// Please note that these restrictions can be easily bypassed.
+	FileExtensions []ExtensionsFilter `json:"file_extensions,omitempty"`
 }
 }
 
 
 // Filesystem defines cloud storage filesystem details
 // Filesystem defines cloud storage filesystem details
@@ -230,6 +257,41 @@ func (u *User) IsLoginMethodAllowed(loginMetod string) bool {
 	return true
 	return true
 }
 }
 
 
+// IsFileAllowed returns true if the specified file is allowed by the file restrictions filters
+func (u *User) IsFileAllowed(sftpPath string) bool {
+	if len(u.Filters.FileExtensions) == 0 {
+		return true
+	}
+	dirsForPath := utils.GetDirsForSFTPPath(path.Dir(sftpPath))
+	var filter ExtensionsFilter
+	for _, dir := range dirsForPath {
+		for _, f := range u.Filters.FileExtensions {
+			if f.Path == dir {
+				filter = f
+				break
+			}
+		}
+		if len(filter.Path) > 0 {
+			break
+		}
+	}
+	if len(filter.Path) > 0 {
+		toMatch := strings.ToLower(sftpPath)
+		for _, denied := range filter.DeniedExtensions {
+			if strings.HasSuffix(toMatch, denied) {
+				return false
+			}
+		}
+		for _, allowed := range filter.AllowedExtensions {
+			if strings.HasSuffix(toMatch, allowed) {
+				return true
+			}
+		}
+		return len(filter.AllowedExtensions) == 0
+	}
+	return true
+}
+
 // IsLoginFromAddrAllowed returns true if the login is allowed from the specified remoteAddr.
 // IsLoginFromAddrAllowed returns true if the login is allowed from the specified remoteAddr.
 // If AllowedIP is defined only the specified IP/Mask can login.
 // If AllowedIP is defined only the specified IP/Mask can login.
 // If DeniedIP is defined the specified IP/Mask cannot login.
 // If DeniedIP is defined the specified IP/Mask cannot login.
@@ -463,6 +525,8 @@ func (u *User) getACopy() User {
 	copy(filters.DeniedIP, u.Filters.DeniedIP)
 	copy(filters.DeniedIP, u.Filters.DeniedIP)
 	filters.DeniedLoginMethods = make([]string, len(u.Filters.DeniedLoginMethods))
 	filters.DeniedLoginMethods = make([]string, len(u.Filters.DeniedLoginMethods))
 	copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods)
 	copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods)
+	filters.FileExtensions = make([]ExtensionsFilter, len(u.Filters.FileExtensions))
+	copy(filters.FileExtensions, u.Filters.FileExtensions)
 	fsConfig := Filesystem{
 	fsConfig := Filesystem{
 		Provider: u.FsConfig.Provider,
 		Provider: u.FsConfig.Provider,
 		S3Config: vfs.S3FsConfig{
 		S3Config: vfs.S3FsConfig{

+ 6 - 0
httpd/api_user.go

@@ -103,11 +103,13 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	user, err := dataprovider.GetUserByID(dataProvider, userID)
 	user, err := dataprovider.GetUserByID(dataProvider, userID)
 	currentPermissions := user.Permissions
 	currentPermissions := user.Permissions
+	currentFileExtensions := user.Filters.FileExtensions
 	currentS3AccessSecret := ""
 	currentS3AccessSecret := ""
 	if user.FsConfig.Provider == 1 {
 	if user.FsConfig.Provider == 1 {
 		currentS3AccessSecret = user.FsConfig.S3Config.AccessSecret
 		currentS3AccessSecret = user.FsConfig.S3Config.AccessSecret
 	}
 	}
 	user.Permissions = make(map[string][]string)
 	user.Permissions = make(map[string][]string)
+	user.Filters.FileExtensions = []dataprovider.ExtensionsFilter{}
 	if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
 	if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
 		sendAPIResponse(w, r, err, "", http.StatusNotFound)
 		sendAPIResponse(w, r, err, "", http.StatusNotFound)
 		return
 		return
@@ -124,6 +126,10 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 	if len(user.Permissions) == 0 {
 	if len(user.Permissions) == 0 {
 		user.Permissions = currentPermissions
 		user.Permissions = currentPermissions
 	}
 	}
+	// we use new file extensions if passed otherwise the old ones
+	if len(user.Filters.FileExtensions) == 0 {
+		user.Filters.FileExtensions = currentFileExtensions
+	}
 	// we use the new access secret if different from the old one and not empty
 	// we use the new access secret if different from the old one and not empty
 	if user.FsConfig.Provider == 1 {
 	if user.FsConfig.Provider == 1 {
 		if utils.RemoveDecryptionKey(currentS3AccessSecret) == user.FsConfig.S3Config.AccessSecret ||
 		if utils.RemoveDecryptionKey(currentS3AccessSecret) == user.FsConfig.S3Config.AccessSecret ||

+ 34 - 0
httpd/api_utils.go

@@ -544,6 +544,40 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User)
 			return errors.New("Denied login methods contents mismatch")
 			return errors.New("Denied login methods contents mismatch")
 		}
 		}
 	}
 	}
+	if err := compareUserFileExtensionsFilters(expected, actual); err != nil {
+		return err
+	}
+	return nil
+}
+
+func compareUserFileExtensionsFilters(expected *dataprovider.User, actual *dataprovider.User) error {
+	if len(expected.Filters.FileExtensions) != len(actual.Filters.FileExtensions) {
+		return errors.New("file extensions mismatch")
+	}
+	for _, f := range expected.Filters.FileExtensions {
+		found := false
+		for _, f1 := range actual.Filters.FileExtensions {
+			if path.Clean(f.Path) == path.Clean(f1.Path) {
+				if len(f.AllowedExtensions) != len(f1.AllowedExtensions) || len(f.DeniedExtensions) != len(f1.DeniedExtensions) {
+					return errors.New("file extensions contents mismatch")
+				}
+				for _, e := range f.AllowedExtensions {
+					if !utils.IsStringInSlice(e, f1.AllowedExtensions) {
+						return errors.New("file extensions contents mismatch")
+					}
+				}
+				for _, e := range f.DeniedExtensions {
+					if !utils.IsStringInSlice(e, f1.DeniedExtensions) {
+						return errors.New("file extensions contents mismatch")
+					}
+				}
+				found = true
+			}
+		}
+		if !found {
+			return errors.New("file extensions contents mismatch")
+		}
+	}
 	return nil
 	return nil
 }
 }
 
 

+ 65 - 8
httpd/httpd_test.go

@@ -325,6 +325,45 @@ func TestAddUserInvalidFilters(t *testing.T) {
 	if err != nil {
 	if err != nil {
 		t.Errorf("unexpected error adding user with invalid filters: %v", err)
 		t.Errorf("unexpected error adding user with invalid filters: %v", err)
 	}
 	}
+	u.Filters.DeniedLoginMethods = []string{}
+	u.Filters.FileExtensions = []dataprovider.ExtensionsFilter{
+		dataprovider.ExtensionsFilter{
+			Path:              "relative",
+			AllowedExtensions: []string{},
+			DeniedExtensions:  []string{},
+		},
+	}
+	_, _, err = httpd.AddUser(u, http.StatusBadRequest)
+	if err != nil {
+		t.Errorf("unexpected error adding user with invalid extensions filters: %v", err)
+	}
+	u.Filters.FileExtensions = []dataprovider.ExtensionsFilter{
+		dataprovider.ExtensionsFilter{
+			Path:              "/",
+			AllowedExtensions: []string{},
+			DeniedExtensions:  []string{},
+		},
+	}
+	_, _, err = httpd.AddUser(u, http.StatusBadRequest)
+	if err != nil {
+		t.Errorf("unexpected error adding user with invalid extensions filters: %v", err)
+	}
+	u.Filters.FileExtensions = []dataprovider.ExtensionsFilter{
+		dataprovider.ExtensionsFilter{
+			Path:              "/subdir",
+			AllowedExtensions: []string{".zip"},
+			DeniedExtensions:  []string{},
+		},
+		dataprovider.ExtensionsFilter{
+			Path:              "/subdir",
+			AllowedExtensions: []string{".rar"},
+			DeniedExtensions:  []string{".jpg"},
+		},
+	}
+	_, _, err = httpd.AddUser(u, http.StatusBadRequest)
+	if err != nil {
+		t.Errorf("unexpected error adding user with invalid extensions filters: %v", err)
+	}
 }
 }
 
 
 func TestAddUserInvalidFsConfig(t *testing.T) {
 func TestAddUserInvalidFsConfig(t *testing.T) {
@@ -549,6 +588,11 @@ func TestUpdateUser(t *testing.T) {
 	user.Filters.AllowedIP = []string{"192.168.1.0/24", "192.168.2.0/24"}
 	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.DeniedIP = []string{"192.168.3.0/24", "192.168.4.0/24"}
 	user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword}
 	user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword}
+	user.Filters.FileExtensions = append(user.Filters.FileExtensions, dataprovider.ExtensionsFilter{
+		Path:              "/subdir",
+		AllowedExtensions: []string{".zip", ".rar"},
+		DeniedExtensions:  []string{".jpg", ".png"},
+	})
 	user.UploadBandwidth = 1024
 	user.UploadBandwidth = 1024
 	user.DownloadBandwidth = 512
 	user.DownloadBandwidth = 512
 	user.VirtualFolders = nil
 	user.VirtualFolders = nil
@@ -1706,6 +1750,8 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("permissions", "*")
 	form.Set("permissions", "*")
 	form.Set("sub_dirs_permissions", " /subdir::list ,download ")
 	form.Set("sub_dirs_permissions", " /subdir::list ,download ")
 	form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v ", mappedDir))
 	form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v ", mappedDir))
+	form.Set("allowed_extensions", "/dir1::.jpg,.png")
+	form.Set("denied_extensions", "/dir1::.zip")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	// test invalid url escape
 	// test invalid url escape
 	req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
 	req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
@@ -1845,6 +1891,10 @@ func TestWebUserAddMock(t *testing.T) {
 	if !vfolderFoumd {
 	if !vfolderFoumd {
 		t.Errorf("virtual folders must contain /vdir, actual: %+v", newUser.VirtualFolders)
 		t.Errorf("virtual folders must contain /vdir, actual: %+v", newUser.VirtualFolders)
 	}
 	}
+	extFilters := newUser.Filters.FileExtensions[0]
+	if !utils.IsStringInSlice(".zip", extFilters.DeniedExtensions) {
+		t.Errorf("unexpected denied extensions: %v", extFilters.DeniedExtensions)
+	}
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil)
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	checkResponseCode(t, http.StatusOK, rr.Code)
@@ -1880,6 +1930,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	form.Set("expiration_date", "2020-01-01 00:00:00")
 	form.Set("expiration_date", "2020-01-01 00:00:00")
 	form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ")
 	form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ")
 	form.Set("denied_ip", " 10.0.0.2/32 ")
 	form.Set("denied_ip", " 10.0.0.2/32 ")
+	form.Set("denied_extensions", "/dir1::.zip")
 	form.Set("ssh_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive)
 	form.Set("ssh_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive)
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
@@ -1890,10 +1941,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	var users []dataprovider.User
 	var users []dataprovider.User
-	err = render.DecodeJSON(rr.Body, &users)
-	if err != nil {
-		t.Errorf("Error decoding users: %v", err)
-	}
+	render.DecodeJSON(rr.Body, &users)
 	if len(users) != 1 {
 	if len(users) != 1 {
 		t.Errorf("1 user is expected")
 		t.Errorf("1 user is expected")
 	}
 	}
@@ -1929,6 +1977,9 @@ func TestWebUserUpdateMock(t *testing.T) {
 	if !utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, updateUser.Filters.DeniedLoginMethods) {
 	if !utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, updateUser.Filters.DeniedLoginMethods) {
 		t.Errorf("Denied login methods does not match: %v", updateUser.Filters.DeniedLoginMethods)
 		t.Errorf("Denied login methods does not match: %v", updateUser.Filters.DeniedLoginMethods)
 	}
 	}
+	if !utils.IsStringInSlice(".zip", updateUser.Filters.FileExtensions[0].DeniedExtensions) {
+		t.Errorf("unexpected extensions filter: %+v", updateUser.Filters.FileExtensions)
+	}
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	checkResponseCode(t, http.StatusOK, rr.Code)
@@ -1976,6 +2027,8 @@ func TestWebUserS3Mock(t *testing.T) {
 	form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
 	form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
 	form.Set("s3_endpoint", user.FsConfig.S3Config.Endpoint)
 	form.Set("s3_endpoint", user.FsConfig.S3Config.Endpoint)
 	form.Set("s3_key_prefix", user.FsConfig.S3Config.KeyPrefix)
 	form.Set("s3_key_prefix", user.FsConfig.S3Config.KeyPrefix)
+	form.Set("allowed_extensions", "/dir1::.jpg,.png")
+	form.Set("denied_extensions", "/dir2::.zip")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
 	req.Header.Set("Content-Type", contentType)
 	req.Header.Set("Content-Type", contentType)
@@ -2020,6 +2073,9 @@ func TestWebUserS3Mock(t *testing.T) {
 	if updateUser.FsConfig.S3Config.KeyPrefix != user.FsConfig.S3Config.KeyPrefix {
 	if updateUser.FsConfig.S3Config.KeyPrefix != user.FsConfig.S3Config.KeyPrefix {
 		t.Error("s3 key prefix mismatch")
 		t.Error("s3 key prefix mismatch")
 	}
 	}
+	if len(updateUser.Filters.FileExtensions) != 2 {
+		t.Errorf("unexpected extensions filter: %+v", updateUser.Filters.FileExtensions)
+	}
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
 	req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	checkResponseCode(t, http.StatusOK, rr.Code)
@@ -2064,6 +2120,7 @@ func TestWebUserGCSMock(t *testing.T) {
 	form.Set("gcs_bucket", user.FsConfig.GCSConfig.Bucket)
 	form.Set("gcs_bucket", user.FsConfig.GCSConfig.Bucket)
 	form.Set("gcs_storage_class", user.FsConfig.GCSConfig.StorageClass)
 	form.Set("gcs_storage_class", user.FsConfig.GCSConfig.StorageClass)
 	form.Set("gcs_key_prefix", user.FsConfig.GCSConfig.KeyPrefix)
 	form.Set("gcs_key_prefix", user.FsConfig.GCSConfig.KeyPrefix)
+	form.Set("allowed_extensions", "/dir1::.jpg,.png")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
 	req.Header.Set("Content-Type", contentType)
 	req.Header.Set("Content-Type", contentType)
@@ -2087,10 +2144,7 @@ func TestWebUserGCSMock(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	checkResponseCode(t, http.StatusOK, rr.Code)
 	var users []dataprovider.User
 	var users []dataprovider.User
-	err = render.DecodeJSON(rr.Body, &users)
-	if err != nil {
-		t.Errorf("Error decoding users: %v", err)
-	}
+	render.DecodeJSON(rr.Body, &users)
 	if len(users) != 1 {
 	if len(users) != 1 {
 		t.Errorf("1 user is expected")
 		t.Errorf("1 user is expected")
 	}
 	}
@@ -2110,6 +2164,9 @@ func TestWebUserGCSMock(t *testing.T) {
 	if updateUser.FsConfig.GCSConfig.KeyPrefix != user.FsConfig.GCSConfig.KeyPrefix {
 	if updateUser.FsConfig.GCSConfig.KeyPrefix != user.FsConfig.GCSConfig.KeyPrefix {
 		t.Error("GCS key prefix mismatch")
 		t.Error("GCS key prefix mismatch")
 	}
 	}
+	if updateUser.Filters.FileExtensions[0].Path != "/dir1" {
+		t.Errorf("unexpected extensions filter: %+v", updateUser.Filters.FileExtensions)
+	}
 	form.Set("gcs_auto_credentials", "on")
 	form.Set("gcs_auto_credentials", "on")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)

+ 47 - 0
httpd/internal_test.go

@@ -165,6 +165,53 @@ func TestCompareUserFilters(t *testing.T) {
 	if err == nil {
 	if err == nil {
 		t.Errorf("Denied login methods contents are not equal")
 		t.Errorf("Denied login methods contents are not equal")
 	}
 	}
+	expected.Filters.DeniedLoginMethods = []string{}
+	actual.Filters.DeniedLoginMethods = []string{}
+	expected.Filters.FileExtensions = append(expected.Filters.FileExtensions, dataprovider.ExtensionsFilter{
+		Path:              "/",
+		AllowedExtensions: []string{".jpg", ".png"},
+		DeniedExtensions:  []string{".zip", ".rar"},
+	})
+	err = checkUser(expected, actual)
+	if err == nil {
+		t.Errorf("file extensons are not equal")
+	}
+	actual.Filters.FileExtensions = append(actual.Filters.FileExtensions, dataprovider.ExtensionsFilter{
+		Path:              "/sub",
+		AllowedExtensions: []string{".jpg", ".png"},
+		DeniedExtensions:  []string{".zip", ".rar"},
+	})
+	err = checkUser(expected, actual)
+	if err == nil {
+		t.Errorf("file extensons contents are not equal")
+	}
+	actual.Filters.FileExtensions[0] = dataprovider.ExtensionsFilter{
+		Path:              "/",
+		AllowedExtensions: []string{".jpg"},
+		DeniedExtensions:  []string{".zip", ".rar"},
+	}
+	err = checkUser(expected, actual)
+	if err == nil {
+		t.Errorf("file extensons contents are not equal")
+	}
+	actual.Filters.FileExtensions[0] = dataprovider.ExtensionsFilter{
+		Path:              "/",
+		AllowedExtensions: []string{".tiff", ".png"},
+		DeniedExtensions:  []string{".zip", ".rar"},
+	}
+	err = checkUser(expected, actual)
+	if err == nil {
+		t.Errorf("file extensons contents are not equal")
+	}
+	actual.Filters.FileExtensions[0] = dataprovider.ExtensionsFilter{
+		Path:              "/",
+		AllowedExtensions: []string{".jpg", ".png"},
+		DeniedExtensions:  []string{".tar.gz", ".rar"},
+	}
+	err = checkUser(expected, actual)
+	if err == nil {
+		t.Errorf("file extensons contents are not equal")
+	}
 }
 }
 
 
 func TestCompareUserFields(t *testing.T) {
 func TestCompareUserFields(t *testing.T) {

+ 27 - 1
httpd/schema/openapi.yaml

@@ -2,7 +2,7 @@ openapi: 3.0.1
 info:
 info:
   title: SFTPGo
   title: SFTPGo
   description: 'SFTPGo REST API'
   description: 'SFTPGo REST API'
-  version: 1.8.2
+  version: 1.8.3
 
 
 servers:
 servers:
 - url: /api/v1
 - url: /api/v1
@@ -933,6 +933,26 @@ components:
         - 'publickey'
         - 'publickey'
         - 'password'
         - 'password'
         - 'keyboard-interactive'
         - 'keyboard-interactive'
+    ExtensionsFilter:
+      type: object
+      properties:
+        path:
+          type: string
+          description: SFTP/SCP path, if no other specific filter is defined, the filter apply for sub directories too. For example if filters are defined for the paths "/" and "/sub" then the filters for "/" are applied for any file outside the "/sub" directory
+        allowed_extensions:
+          type: array
+          items:
+            type: string
+          nullable: true
+          description: list of, case insensitive, allowed files extension. Shell like expansion is not supported so you have to specify `.jpg` and not `*.jpg`
+          example: [ ".jpg", ".png" ]
+        denied_extensions:
+          type: array
+          items:
+            type: string
+          nullable: true
+          description: list of, case insensitive, denied files extension. Denied file extensions are evaluated before the allowed ones
+          example: [ ".zip" ]
     UserFilters:
     UserFilters:
       type: object
       type: object
       properties:
       properties:
@@ -956,6 +976,12 @@ components:
             $ref: '#/components/schemas/LoginMethods'
             $ref: '#/components/schemas/LoginMethods'
           nullable: true
           nullable: true
           description: if null or empty any available login method is allowed
           description: if null or empty any available login method is allowed
+        file_extensions:
+          type: array
+          items:
+            $ref: '#/components/schemas/ExtensionsFilter'
+          nullable: true
+          description: filters based on file extensions. 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
       description: Additional restrictions
       description: Additional restrictions
     S3Config:
     S3Config:
       type: object
       type: object

+ 65 - 0
httpd/web.go

@@ -7,6 +7,7 @@ import (
 	"html/template"
 	"html/template"
 	"io/ioutil"
 	"io/ioutil"
 	"net/http"
 	"net/http"
+	"path"
 	"path/filepath"
 	"path/filepath"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
@@ -241,11 +242,75 @@ func getSliceFromDelimitedValues(values, delimiter string) []string {
 	return result
 	return result
 }
 }
 
 
+func getFileExtensionsFromPostField(value string, extesionsType int) []dataprovider.ExtensionsFilter {
+	var result []dataprovider.ExtensionsFilter
+	for _, cleaned := range getSliceFromDelimitedValues(value, "\n") {
+		if strings.Contains(cleaned, "::") {
+			dirExts := strings.Split(cleaned, "::")
+			if len(dirExts) > 1 {
+				dir := dirExts[0]
+				dir = strings.TrimSpace(dir)
+				exts := []string{}
+				for _, e := range strings.Split(dirExts[1], ",") {
+					cleanedExt := strings.TrimSpace(e)
+					if len(cleanedExt) > 0 {
+						exts = append(exts, cleanedExt)
+					}
+				}
+				if len(dir) > 0 {
+					filter := dataprovider.ExtensionsFilter{
+						Path: dir,
+					}
+					if extesionsType == 1 {
+						filter.AllowedExtensions = exts
+						filter.DeniedExtensions = []string{}
+					} else {
+						filter.DeniedExtensions = exts
+						filter.AllowedExtensions = []string{}
+					}
+					result = append(result, filter)
+				}
+			}
+		}
+	}
+	return result
+}
+
 func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters {
 func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters {
 	var filters dataprovider.UserFilters
 	var filters dataprovider.UserFilters
 	filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
 	filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
 	filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
 	filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
 	filters.DeniedLoginMethods = r.Form["ssh_login_methods"]
 	filters.DeniedLoginMethods = r.Form["ssh_login_methods"]
+	allowedExtensions := getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), 1)
+	deniedExtensions := getFileExtensionsFromPostField(r.Form.Get("denied_extensions"), 2)
+	extensions := []dataprovider.ExtensionsFilter{}
+	if len(allowedExtensions) > 0 && len(deniedExtensions) > 0 {
+		for _, allowed := range allowedExtensions {
+			for _, denied := range deniedExtensions {
+				if path.Clean(allowed.Path) == path.Clean(denied.Path) {
+					allowed.DeniedExtensions = append(allowed.DeniedExtensions, denied.DeniedExtensions...)
+				}
+			}
+			extensions = append(extensions, allowed)
+		}
+		for _, denied := range deniedExtensions {
+			found := false
+			for _, allowed := range allowedExtensions {
+				if path.Clean(denied.Path) == path.Clean(allowed.Path) {
+					found = true
+					break
+				}
+			}
+			if !found {
+				extensions = append(extensions, denied)
+			}
+		}
+	} else if len(allowedExtensions) > 0 {
+		extensions = append(extensions, allowedExtensions...)
+	} else if len(deniedExtensions) > 0 {
+		extensions = append(extensions, deniedExtensions...)
+	}
+	filters.FileExtensions = extensions
 	return filters
 	return filters
 }
 }
 
 

+ 25 - 2
scripts/README.md

@@ -44,7 +44,7 @@ Let's see a sample usage for each REST API.
 Command:
 Command:
 
 
 ```
 ```
-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/" --denied-login-methods "password" "keyboard-interactive"
+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/" --denied-login-methods "password" "keyboard-interactive" --allowed-extensions "/dir1::.jpg,.png" "/dir2::.rar,.png" --denied-extensions "/dir3::.zip,.rar"
 ```
 ```
 
 
 Output:
 Output:
@@ -73,6 +73,29 @@ Output:
     "denied_login_methods": [
     "denied_login_methods": [
       "password",
       "password",
       "keyboard-interactive"
       "keyboard-interactive"
+    ],
+    "file_extensions": [
+      {
+        "allowed_extensions": [
+          ".jpg",
+          ".png"
+        ],
+        "path": "/dir1"
+      },
+      {
+        "allowed_extensions": [
+          ".rar",
+          ".png"
+        ],
+        "path": "/dir2"
+      },
+      {
+        "denied_extensions": [
+          ".zip",
+          ".rar"
+        ],
+        "path": "/dir3"
+      }
     ]
     ]
   },
   },
   "gid": 1000,
   "gid": 1000,
@@ -115,7 +138,7 @@ Output:
 Command:
 Command:
 
 
 ```
 ```
-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" "/vdir2::/tmp/mapped2"
+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" "/vdir2::/tmp/mapped2" --allowed-extensions "" --denied-extensions ""
 ```
 ```
 
 
 Output:
 Output:

+ 70 - 10
scripts/sftpgo_api_cli.py

@@ -76,7 +76,8 @@ class SFTPGoApiRequests:
 					status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='',
 					status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='',
 					s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='',
 					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='',
 					s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='',
-					gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[]):
+					gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[],
+					denied_extensions=[], allowed_extensions=[]):
 		user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid,
 		user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid,
 			'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files,
 			'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files,
 			'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth,
 			'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth,
@@ -94,8 +95,9 @@ class SFTPGoApiRequests:
 			user.update({'permissions':permissions})
 			user.update({'permissions':permissions})
 		if virtual_folders:
 		if virtual_folders:
 			user.update({'virtual_folders':self.buildVirtualFolders(virtual_folders)})
 			user.update({'virtual_folders':self.buildVirtualFolders(virtual_folders)})
-		if allowed_ip or denied_ip or denied_login_methods:
-			user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods)})
+		if allowed_ip or denied_ip or denied_login_methods or allowed_extensions or denied_extensions:
+			user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods, denied_extensions,
+													allowed_extensions)})
 		user.update({'filesystem':self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret,
 		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,
 													s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket,
 													gcs_key_prefix, gcs_storage_class, gcs_credentials_file,
 													gcs_key_prefix, gcs_storage_class, gcs_credentials_file,
@@ -133,7 +135,7 @@ class SFTPGoApiRequests:
 					permissions.update({directory:values})
 					permissions.update({directory:values})
 		return permissions
 		return permissions
 
 
-	def buildFilters(self, allowed_ip, denied_ip, denied_login_methods):
+	def buildFilters(self, allowed_ip, denied_ip, denied_login_methods, denied_extensions, allowed_extensions):
 		filters = {}
 		filters = {}
 		if allowed_ip:
 		if allowed_ip:
 			if len(allowed_ip) == 1 and not allowed_ip[0]:
 			if len(allowed_ip) == 1 and not allowed_ip[0]:
@@ -150,6 +152,54 @@ class SFTPGoApiRequests:
 				filters.update({'denied_login_methods':[]})
 				filters.update({'denied_login_methods':[]})
 			else:
 			else:
 				filters.update({'denied_login_methods':denied_login_methods})
 				filters.update({'denied_login_methods':denied_login_methods})
+		extensions_filter = []
+		extensions_denied = []
+		extensions_allowed = []
+		if denied_extensions:
+			for e in denied_extensions:
+				if '::' in e:
+					directory = None
+					values = []
+					for value in e.split('::'):
+						if directory is None:
+							directory = value
+						else:
+							values = [v.strip() for v in value.split(',') if v.strip()]
+					if directory:
+						extensions_denied.append({'path':directory, 'denied_extensions':values,
+												'allowed_extensions':[]})
+		if allowed_extensions:
+			for e in allowed_extensions:
+				if '::' in e:
+					directory = None
+					values = []
+					for value in e.split('::'):
+						if directory is None:
+							directory = value
+						else:
+							values = [v.strip() for v in value.split(',') if v.strip()]
+					if directory:
+						extensions_allowed.append({'path':directory, 'allowed_extensions':values,
+												'denied_extensions':[]})
+		if extensions_allowed and extensions_denied:
+			for allowed in extensions_allowed:
+				for denied in extensions_denied:
+					if allowed.get('path') == denied.get('path'):
+						allowed.update({'denied_extensions':denied.get('denied_extensions')})
+				extensions_filter.append(allowed)
+			for denied in extensions_denied:
+				found = False
+				for allowed in extensions_allowed:
+					if allowed.get('path') == denied.get('path'):
+						found = True
+				if not found:
+					extensions_filter.append(denied)
+		elif extensions_allowed:
+			extensions_filter = extensions_allowed
+		elif extensions_denied:
+			extensions_filter = extensions_denied
+		if allowed_extensions or denied_extensions:
+			filters.update({'file_extensions':extensions_filter})
 		return filters
 		return filters
 
 
 	def buildFsConfig(self, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint,
 	def buildFsConfig(self, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint,
@@ -188,12 +238,13 @@ class SFTPGoApiRequests:
 			subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='',
 			subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='',
 			s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='',
 			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',
 			gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic',
-			denied_login_methods=[], virtual_folders=[]):
+			denied_login_methods=[], virtual_folders=[], denied_extensions=[], allowed_extensions=[]):
 		u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
 		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,
 			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,
 			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,
 			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)
+			gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_extensions,
+			allowed_extensions)
 		r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
 		r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
 		self.printResponse(r)
 		self.printResponse(r)
 
 
@@ -202,12 +253,14 @@ class SFTPGoApiRequests:
 				expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local',
 				expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local',
 				s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='',
 				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='',
 				s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='',
-				gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[]):
+				gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_extensions=[],
+				allowed_extensions=[]):
 		u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
 		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,
 			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,
 			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,
 			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)
+			gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_extensions,
+			allowed_extensions)
 		r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), json=u, auth=self.auth, verify=self.verify)
 		r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), json=u, auth=self.auth, verify=self.verify)
 		self.printResponse(r)
 		self.printResponse(r)
 
 
@@ -466,6 +519,13 @@ def addCommonUserArguments(parser):
 					help='Allowed IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
 					help='Allowed IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
 	parser.add_argument('-N', '--denied-ip', type=str, nargs='+', default=[],
 	parser.add_argument('-N', '--denied-ip', type=str, nargs='+', default=[],
 					help='Denied IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
 					help='Denied IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
+	parser.add_argument('--denied-extensions', type=str, nargs='*', default=[], help='Denied file extensions case insensitive. '
+					+'The format is /dir::ext1,ext2. For example: "/somedir::.jpg,.png" "/otherdir/subdir::.zip,.rar". ' +
+					'You have to set both denied and allowed extensions to update existing values or none to preserve them.' +
+					' If you only set allowed or denied extensions the missing one is assumed to be an empty list. Default: %(default)s')
+	parser.add_argument('--allowed-extensions', type=str, nargs='*', default=[], help='Allowed file extensions case insensitive. '
+					+'The format is /dir::ext1,ext2. For example: "/somedir::.jpg,.png" "/otherdir/subdir::.zip,.rar". ' +
+					'Default: %(default)s')
 	parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3', 'GCS'],
 	parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3', 'GCS'],
 					help='Filesystem provider. Default: %(default)s')
 					help='Filesystem provider. Default: %(default)s')
 	parser.add_argument('--s3-bucket', type=str, default='', help='Default: %(default)s')
 	parser.add_argument('--s3-bucket', type=str, default='', help='Default: %(default)s')
@@ -596,7 +656,7 @@ if __name__ == '__main__':
 				args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret,
 				args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret,
 				args.s3_endpoint, args.s3_storage_class, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix,
 				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.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials,
-				args.denied_login_methods, args.virtual_folders)
+				args.denied_login_methods, args.virtual_folders, args.denied_extensions, args.allowed_extensions)
 	elif args.command == 'update-user':
 	elif args.command == 'update-user':
 		api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
 		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,
 					args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
@@ -605,7 +665,7 @@ if __name__ == '__main__':
 					args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class,
 					args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class,
 					args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_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.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods,
-					args.virtual_folders)
+					args.virtual_folders, args.denied_extensions, args.allowed_extensions)
 	elif args.command == 'delete-user':
 	elif args.command == 'delete-user':
 		api.deleteUser(args.id)
 		api.deleteUser(args.id)
 	elif args.command == 'get-users':
 	elif args.command == 'get-users':

+ 24 - 0
sftpd/handler.go

@@ -51,6 +51,11 @@ func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) {
 		return nil, sftp.ErrSSHFxPermissionDenied
 		return nil, sftp.ErrSSHFxPermissionDenied
 	}
 	}
 
 
+	if !c.User.IsFileAllowed(request.Filepath) {
+		c.Log(logger.LevelWarn, logSender, "reading file %#v is not allowed", request.Filepath)
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+
 	p, err := c.fs.ResolvePath(request.Filepath)
 	p, err := c.fs.ResolvePath(request.Filepath)
 	if err != nil {
 	if err != nil {
 		return nil, vfs.GetSFTPError(c.fs, err)
 		return nil, vfs.GetSFTPError(c.fs, err)
@@ -97,6 +102,12 @@ func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) {
 // Filewrite handles the write actions for a file on the system.
 // Filewrite handles the write actions for a file on the system.
 func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
 func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
 	updateConnectionActivity(c.ID)
 	updateConnectionActivity(c.ID)
+
+	if !c.User.IsFileAllowed(request.Filepath) {
+		c.Log(logger.LevelWarn, logSender, "writing file %#v is not allowed", request.Filepath)
+		return nil, sftp.ErrSSHFxPermissionDenied
+	}
+
 	p, err := c.fs.ResolvePath(request.Filepath)
 	p, err := c.fs.ResolvePath(request.Filepath)
 	if err != nil {
 	if err != nil {
 		return nil, vfs.GetSFTPError(c.fs, err)
 		return nil, vfs.GetSFTPError(c.fs, err)
@@ -309,6 +320,13 @@ func (c Connection) handleSFTPRename(sourcePath string, targetPath string, reque
 		c.Log(logger.LevelWarn, logSender, "renaming a virtual folder is not allowed")
 		c.Log(logger.LevelWarn, logSender, "renaming a virtual folder is not allowed")
 		return sftp.ErrSSHFxPermissionDenied
 		return sftp.ErrSSHFxPermissionDenied
 	}
 	}
+	if !c.User.IsFileAllowed(request.Filepath) || !c.User.IsFileAllowed(request.Target) {
+		if fi, err := c.fs.Lstat(sourcePath); err == nil && fi.Mode().IsRegular() {
+			c.Log(logger.LevelDebug, logSender, "renaming file is not allowed, source: %#v target: %#v", request.Filepath,
+				request.Target)
+			return sftp.ErrSSHFxPermissionDenied
+		}
+	}
 	if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) {
 	if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) {
 		return sftp.ErrSSHFxPermissionDenied
 		return sftp.ErrSSHFxPermissionDenied
 	}
 	}
@@ -409,6 +427,12 @@ func (c Connection) handleSFTPRemove(filePath string, request *sftp.Request) err
 		c.Log(logger.LevelDebug, logSender, "cannot remove %#v is not a file/symlink", filePath)
 		c.Log(logger.LevelDebug, logSender, "cannot remove %#v is not a file/symlink", filePath)
 		return sftp.ErrSSHFxFailure
 		return sftp.ErrSSHFxFailure
 	}
 	}
+
+	if !c.User.IsFileAllowed(request.Filepath) {
+		c.Log(logger.LevelDebug, logSender, "removing file %#v is not allowed", filePath)
+		return sftp.ErrSSHFxPermissionDenied
+	}
+
 	size = fi.Size()
 	size = fi.Size()
 	if err := c.fs.Remove(filePath, false); err != nil {
 	if err := c.fs.Remove(filePath, false); err != nil {
 		c.Log(logger.LevelWarn, logSender, "failed to remove a file/symlink %#v: %+v", filePath, err)
 		c.Log(logger.LevelWarn, logSender, "failed to remove a file/symlink %#v: %+v", filePath, err)

+ 77 - 0
sftpd/internal_test.go

@@ -723,6 +723,83 @@ func TestSSHCommandErrors(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestCommandsWithExtensionsFilter(t *testing.T) {
+	buf := make([]byte, 65535)
+	stdErrBuf := make([]byte, 65535)
+	mockSSHChannel := MockChannel{
+		Buffer:       bytes.NewBuffer(buf),
+		StdErrBuffer: bytes.NewBuffer(stdErrBuf),
+	}
+	server, client := net.Pipe()
+	defer server.Close()
+	defer client.Close()
+	user := dataprovider.User{
+		Username: "test",
+		HomeDir:  os.TempDir(),
+		Status:   1,
+	}
+	user.Filters.FileExtensions = []dataprovider.ExtensionsFilter{
+		dataprovider.ExtensionsFilter{
+			Path:              "/subdir",
+			AllowedExtensions: []string{".jpg"},
+			DeniedExtensions:  []string{},
+		},
+	}
+
+	fs, _ := user.GetFilesystem("123")
+	connection := Connection{
+		channel: &mockSSHChannel,
+		netConn: client,
+		User:    user,
+		fs:      fs,
+	}
+	cmd := sshCommand{
+		command:    "md5sum",
+		connection: connection,
+		args:       []string{"subdir/test.png"},
+	}
+	err := cmd.handleHashCommands()
+	if err != errPermissionDenied {
+		t.Errorf("unexpected error: %v", err)
+	}
+	cmd = sshCommand{
+		command:    "rsync",
+		connection: connection,
+		args:       []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"},
+	}
+	_, err = cmd.getSystemCommand()
+	if err != errUnsupportedConfig {
+		t.Errorf("unexpected error: %v", err)
+	}
+	cmd = sshCommand{
+		command:    "git-receive-pack",
+		connection: connection,
+		args:       []string{"/subdir"},
+	}
+	_, err = cmd.getSystemCommand()
+	if err != errUnsupportedConfig {
+		t.Errorf("unexpected error: %v", err)
+	}
+	cmd = sshCommand{
+		command:    "git-receive-pack",
+		connection: connection,
+		args:       []string{"/subdir/dir"},
+	}
+	_, err = cmd.getSystemCommand()
+	if err != errUnsupportedConfig {
+		t.Errorf("unexpected error: %v", err)
+	}
+	cmd = sshCommand{
+		command:    "git-receive-pack",
+		connection: connection,
+		args:       []string{"/adir/subdir"},
+	}
+	_, err = cmd.getSystemCommand()
+	if err != nil {
+		t.Errorf("unexpected error: %v", err)
+	}
+}
+
 func TestSSHCommandsRemoteFs(t *testing.T) {
 func TestSSHCommandsRemoteFs(t *testing.T) {
 	buf := make([]byte, 65535)
 	buf := make([]byte, 65535)
 	stdErrBuf := make([]byte, 65535)
 	stdErrBuf := make([]byte, 65535)

+ 26 - 19
sftpd/scp.go

@@ -1,6 +1,7 @@
 package sftpd
 package sftpd
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"math"
 	"math"
@@ -19,10 +20,11 @@ import (
 )
 )
 
 
 var (
 var (
-	okMsg   = []byte{0x00}
-	warnMsg = []byte{0x01} // must be followed by an optional message and a newline
-	errMsg  = []byte{0x02} // must be followed by an optional message and a newline
-	newLine = []byte{0x0A}
+	okMsg         = []byte{0x00}
+	warnMsg       = []byte{0x01} // must be followed by an optional message and a newline
+	errMsg        = []byte{0x02} // must be followed by an optional message and a newline
+	newLine       = []byte{0x0A}
+	errPermission = errors.New("Permission denied")
 )
 )
 
 
 type scpCommand struct {
 type scpCommand struct {
@@ -124,10 +126,9 @@ func (c *scpCommand) handleCreateDir(dirPath string) error {
 		return err
 		return err
 	}
 	}
 	if !c.connection.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(dirPath)) {
 	if !c.connection.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(dirPath)) {
-		err := fmt.Errorf("Permission denied")
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error creating dir: %#v, permission denied", dirPath)
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error creating dir: %#v, permission denied", dirPath)
-		c.sendErrorMessage(err.Error())
-		return err
+		c.sendErrorMessage(errPermission.Error())
+		return errPermission
 	}
 	}
 
 
 	err = c.createDir(p)
 	err = c.createDir(p)
@@ -243,6 +244,11 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
 
 
 	updateConnectionActivity(c.connection.ID)
 	updateConnectionActivity(c.connection.ID)
 
 
+	if !c.connection.User.IsFileAllowed(uploadFilePath) {
+		c.connection.Log(logger.LevelWarn, logSenderSCP, "writing file %#v is not allowed", uploadFilePath)
+		c.sendErrorMessage(errPermission.Error())
+	}
+
 	p, err := c.connection.fs.ResolvePath(uploadFilePath)
 	p, err := c.connection.fs.ResolvePath(uploadFilePath)
 	if err != nil {
 	if err != nil {
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error uploading file: %#v, err: %v", uploadFilePath, err)
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error uploading file: %#v, err: %v", uploadFilePath, err)
@@ -256,10 +262,9 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
 	stat, statErr := c.connection.fs.Stat(p)
 	stat, statErr := c.connection.fs.Stat(p)
 	if c.connection.fs.IsNotExist(statErr) {
 	if c.connection.fs.IsNotExist(statErr) {
 		if !c.connection.User.HasPerm(dataprovider.PermUpload, path.Dir(uploadFilePath)) {
 		if !c.connection.User.HasPerm(dataprovider.PermUpload, path.Dir(uploadFilePath)) {
-			err := fmt.Errorf("Permission denied")
 			c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot upload file: %#v, permission denied", uploadFilePath)
 			c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot upload file: %#v, permission denied", uploadFilePath)
-			c.sendErrorMessage(err.Error())
-			return err
+			c.sendErrorMessage(errPermission.Error())
+			return errPermission
 		}
 		}
 		return c.handleUploadFile(p, filePath, sizeToRead, true, 0)
 		return c.handleUploadFile(p, filePath, sizeToRead, true, 0)
 	}
 	}
@@ -278,10 +283,9 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
 	}
 	}
 
 
 	if !c.connection.User.HasPerm(dataprovider.PermOverwrite, uploadFilePath) {
 	if !c.connection.User.HasPerm(dataprovider.PermOverwrite, uploadFilePath) {
-		err := fmt.Errorf("Permission denied")
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot overwrite file: %#v, permission denied", uploadFilePath)
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot overwrite file: %#v, permission denied", uploadFilePath)
-		c.sendErrorMessage(err.Error())
-		return err
+		c.sendErrorMessage(errPermission.Error())
+		return errPermission
 	}
 	}
 
 
 	if isAtomicUploadEnabled() && c.connection.fs.IsAtomicUploadSupported() {
 	if isAtomicUploadEnabled() && c.connection.fs.IsAtomicUploadSupported() {
@@ -461,20 +465,23 @@ func (c *scpCommand) handleDownload(filePath string) error {
 
 
 	if stat.IsDir() {
 	if stat.IsDir() {
 		if !c.connection.User.HasPerm(dataprovider.PermDownload, filePath) {
 		if !c.connection.User.HasPerm(dataprovider.PermDownload, filePath) {
-			err := fmt.Errorf("Permission denied")
 			c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading dir: %#v, permission denied", filePath)
 			c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading dir: %#v, permission denied", filePath)
-			c.sendErrorMessage(err.Error())
-			return err
+			c.sendErrorMessage(errPermission.Error())
+			return errPermission
 		}
 		}
 		err = c.handleRecursiveDownload(p, stat)
 		err = c.handleRecursiveDownload(p, stat)
 		return err
 		return err
 	}
 	}
 
 
 	if !c.connection.User.HasPerm(dataprovider.PermDownload, path.Dir(filePath)) {
 	if !c.connection.User.HasPerm(dataprovider.PermDownload, path.Dir(filePath)) {
-		err := fmt.Errorf("Permission denied")
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading dir: %#v, permission denied", filePath)
 		c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading dir: %#v, permission denied", filePath)
-		c.sendErrorMessage(err.Error())
-		return err
+		c.sendErrorMessage(errPermission.Error())
+		return errPermission
+	}
+
+	if !c.connection.User.IsFileAllowed(filePath) {
+		c.connection.Log(logger.LevelWarn, logSenderSCP, "reading file %#v is not allowed", filePath)
+		c.sendErrorMessage(errPermission.Error())
 	}
 	}
 
 
 	file, r, cancelFn, err := c.connection.fs.Open(p)
 	file, r, cancelFn, err := c.connection.fs.Open(p)

+ 181 - 0
sftpd/sftpd_test.go

@@ -2115,6 +2115,81 @@ func TestBandwidthAndConnections(t *testing.T) {
 	os.RemoveAll(user.GetHomeDir())
 	os.RemoveAll(user.GetHomeDir())
 }
 }
 
 
+func TestExtensionsFilters(t *testing.T) {
+	usePubKey := true
+	u := getTestUser(usePubKey)
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to add user: %v", err)
+	}
+	testFileSize := int64(131072)
+	testFileName := "test_file.dat"
+	testFilePath := filepath.Join(homeBasePath, testFileName)
+	localDownloadPath := filepath.Join(homeBasePath, "test_download.dat")
+	client, err := getSftpClient(user, usePubKey)
+	if err != nil {
+		t.Errorf("unable to create sftp client: %v", err)
+	} else {
+		defer client.Close()
+		err = createTestFile(testFilePath, testFileSize)
+		if err != nil {
+			t.Errorf("unable to create test file: %v", err)
+		}
+		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
+		if err != nil {
+			t.Errorf("file upload error: %v", err)
+		}
+	}
+	user.Filters.FileExtensions = []dataprovider.ExtensionsFilter{
+		dataprovider.ExtensionsFilter{
+			Path:              "/",
+			AllowedExtensions: []string{".zip"},
+			DeniedExtensions:  []string{},
+		},
+	}
+	_, _, err = httpd.UpdateUser(user, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to update user: %v", err)
+	}
+	client, err = getSftpClient(user, usePubKey)
+	if err != nil {
+		t.Errorf("unable to create sftp client: %v", err)
+	} else {
+		defer client.Close()
+		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
+		if err == nil {
+			t.Error("file upload must fail")
+		}
+		err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
+		if err == nil {
+			t.Error("file download must fail")
+		}
+		err = client.Rename(testFileName, testFileName+"1")
+		if err == nil {
+			t.Error("rename must fail")
+		}
+		err = client.Remove(testFileName)
+		if err == nil {
+			t.Error("remove must fail")
+		}
+		err = client.Mkdir("dir.zip")
+		if err != nil {
+			t.Errorf("unexpected error: %v", err)
+		}
+		err = client.Rename("dir.zip", "dir1.zip")
+		if err != nil {
+			t.Errorf("unexpected error: %v", err)
+		}
+	}
+	_, err = httpd.RemoveUser(user, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to remove user: %v", err)
+	}
+	os.Remove(testFilePath)
+	os.Remove(localDownloadPath)
+	os.RemoveAll(user.GetHomeDir())
+}
+
 func TestVirtualFolders(t *testing.T) {
 func TestVirtualFolders(t *testing.T) {
 	usePubKey := true
 	usePubKey := true
 	u := getTestUser(usePubKey)
 	u := getTestUser(usePubKey)
@@ -3562,6 +3637,60 @@ func TestUserPerms(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestFilterFileExtensions(t *testing.T) {
+	user := getTestUser(true)
+	extension := dataprovider.ExtensionsFilter{
+		Path:              "/test",
+		AllowedExtensions: []string{".jpg", ".png"},
+		DeniedExtensions:  []string{".pdf"},
+	}
+	filters := dataprovider.UserFilters{
+		FileExtensions: []dataprovider.ExtensionsFilter{extension},
+	}
+	user.Filters = filters
+	if !user.IsFileAllowed("/test/test.jPg") {
+		t.Error("this file must be allowed")
+	}
+	if user.IsFileAllowed("/test/test.pdf") {
+		t.Error("this file must be denied")
+	}
+	if !user.IsFileAllowed("/test.pDf") {
+		t.Error("this file must be allowed")
+	}
+	filters.FileExtensions = append(filters.FileExtensions, dataprovider.ExtensionsFilter{
+		Path:              "/",
+		AllowedExtensions: []string{".zip", ".rar", ".pdf"},
+		DeniedExtensions:  []string{".gz"},
+	})
+	user.Filters = filters
+	if user.IsFileAllowed("/test1/test.gz") {
+		t.Error("this file must be denied")
+	}
+	if !user.IsFileAllowed("/test1/test.zip") {
+		t.Error("this file must be allowed")
+	}
+	if user.IsFileAllowed("/test/sub/test.pdf") {
+		t.Error("this file must be denied")
+	}
+	if user.IsFileAllowed("/test1/test.png") {
+		t.Error("this file must be denied")
+	}
+	filters.FileExtensions = append(filters.FileExtensions, dataprovider.ExtensionsFilter{
+		Path:             "/test/sub",
+		DeniedExtensions: []string{".tar"},
+	})
+	user.Filters = filters
+	if user.IsFileAllowed("/test/sub/sub/test.tar") {
+		t.Error("this file must be denied")
+	}
+	if !user.IsFileAllowed("/test/sub/test.gz") {
+		t.Error("this file must be allowed")
+	}
+	if user.IsFileAllowed("/test/test.zip") {
+		t.Error("this file must be denied")
+	}
+}
+
 func TestUserEmptySubDirPerms(t *testing.T) {
 func TestUserEmptySubDirPerms(t *testing.T) {
 	user := getTestUser(true)
 	user := getTestUser(true)
 	user.Permissions = make(map[string][]string)
 	user.Permissions = make(map[string][]string)
@@ -4048,6 +4177,58 @@ func TestSCPRecursive(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestSCPExtensionsFilter(t *testing.T) {
+	if len(scpPath) == 0 {
+		t.Skip("scp command not found, unable to execute this test")
+	}
+	usePubKey := true
+	u := getTestUser(usePubKey)
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to add user: %v", err)
+	}
+	testFileSize := int64(131072)
+	testFileName := "test_file.dat"
+	testFilePath := filepath.Join(homeBasePath, testFileName)
+	localPath := filepath.Join(homeBasePath, "scp_download.dat")
+	remoteUpPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, "/")
+	remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", testFileName))
+	err = createTestFile(testFilePath, testFileSize)
+	if err != nil {
+		t.Errorf("unable to create test file: %v", err)
+	}
+	err = scpUpload(testFilePath, remoteUpPath, false, false)
+	if err != nil {
+		t.Errorf("error uploading file via scp: %v", err)
+	}
+	user.Filters.FileExtensions = []dataprovider.ExtensionsFilter{
+		dataprovider.ExtensionsFilter{
+			Path:              "/",
+			AllowedExtensions: []string{".zip"},
+			DeniedExtensions:  []string{},
+		},
+	}
+	_, _, err = httpd.UpdateUser(user, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to update user: %v", err)
+	}
+	err = scpDownload(localPath, remoteDownPath, false, false)
+	if err == nil {
+		t.Errorf("scp download must fail")
+	}
+	err = scpUpload(testFilePath, remoteUpPath, false, false)
+	if err == nil {
+		t.Error("scp upload must fail")
+	}
+	_, err = httpd.RemoveUser(user, http.StatusOK)
+	if err != nil {
+		t.Errorf("unable to remove user: %v", err)
+	}
+	os.Remove(testFilePath)
+	os.Remove(localPath)
+	os.RemoveAll(user.GetHomeDir())
+}
+
 func TestSCPVirtualFolders(t *testing.T) {
 func TestSCPVirtualFolders(t *testing.T) {
 	if len(scpPath) == 0 {
 	if len(scpPath) == 0 {
 		t.Skip("scp command not found, unable to execute this test")
 		t.Skip("scp command not found, unable to execute this test")

+ 49 - 17
sftpd/ssh_cmd.go

@@ -129,6 +129,10 @@ func (c *sshCommand) handleHashCommands() error {
 		response = fmt.Sprintf("%x  -\n", h.Sum(nil))
 		response = fmt.Sprintf("%x  -\n", h.Sum(nil))
 	} else {
 	} else {
 		sshPath := c.getDestPath()
 		sshPath := c.getDestPath()
+		if !c.connection.User.IsFileAllowed(sshPath) {
+			c.connection.Log(logger.LevelInfo, logSenderSSH, "hash not allowed for file %#v", sshPath)
+			return c.sendErrorResponse(errPermissionDenied)
+		}
 		fsPath, err := c.connection.fs.ResolvePath(sshPath)
 		fsPath, err := c.connection.fs.ResolvePath(sshPath)
 		if err != nil {
 		if err != nil {
 			return c.sendErrorResponse(err)
 			return c.sendErrorResponse(err)
@@ -284,6 +288,41 @@ func (c *sshCommand) executeSystemCommand(command systemCommand) error {
 	return err
 	return err
 }
 }
 
 
+func (c *sshCommand) checkGitAllowed() error {
+	gitPath := c.getDestPath()
+	for _, v := range c.connection.User.VirtualFolders {
+		if v.VirtualPath == gitPath {
+			c.connection.Log(logger.LevelDebug, logSenderSSH, "git is not supported inside virtual folder %#v user %#v",
+				gitPath, c.connection.User.Username)
+			return errUnsupportedConfig
+		}
+		if len(gitPath) > len(v.VirtualPath) {
+			if strings.HasPrefix(gitPath, v.VirtualPath+"/") {
+				c.connection.Log(logger.LevelDebug, logSenderSSH, "git is not supported inside virtual folder %#v user %#v",
+					gitPath, c.connection.User.Username)
+				return errUnsupportedConfig
+			}
+		}
+	}
+	for _, f := range c.connection.User.Filters.FileExtensions {
+		if f.Path == gitPath {
+			c.connection.Log(logger.LevelDebug, logSenderSSH,
+				"git is not supported inside folder with files extensions filters %#v user %#v", gitPath,
+				c.connection.User.Username)
+			return errUnsupportedConfig
+		}
+		if len(gitPath) > len(f.Path) {
+			if strings.HasPrefix(gitPath, f.Path+"/") || f.Path == "/" {
+				c.connection.Log(logger.LevelDebug, logSenderSSH,
+					"git is not supported inside folder with files extensions filters %#v user %#v", gitPath,
+					c.connection.User.Username)
+				return errUnsupportedConfig
+			}
+		}
+	}
+	return nil
+}
+
 func (c *sshCommand) getSystemCommand() (systemCommand, error) {
 func (c *sshCommand) getSystemCommand() (systemCommand, error) {
 	command := systemCommand{
 	command := systemCommand{
 		cmd:      nil,
 		cmd:      nil,
@@ -303,31 +342,24 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) {
 		args = append(args, path)
 		args = append(args, path)
 	}
 	}
 	if strings.HasPrefix(c.command, "git-") {
 	if strings.HasPrefix(c.command, "git-") {
-		// we don't allow git inside virtual folders
-		gitPath := c.getDestPath()
-		for _, v := range c.connection.User.VirtualFolders {
-			if v.VirtualPath == gitPath {
-				c.connection.Log(logger.LevelDebug, logSenderSSH, "git is not supported inside virtual folder %#v user %#v",
-					gitPath, c.connection.User.Username)
-				return command, errUnsupportedConfig
-			}
-			if len(gitPath) > len(v.VirtualPath) {
-				if strings.HasPrefix(gitPath, v.VirtualPath+"/") {
-					c.connection.Log(logger.LevelDebug, logSenderSSH, "git is not supported inside virtual folder %#v user %#v",
-						gitPath, c.connection.User.Username)
-					return command, errUnsupportedConfig
-				}
-			}
+		// we don't allow git inside virtual folders or folders with files extensions filters
+		if err := c.checkGitAllowed(); err != nil {
+			return command, err
 		}
 		}
 	}
 	}
 	if c.command == "rsync" {
 	if c.command == "rsync" {
-		// if the user has virtual folders we don't allow rsync since the rsync command interacts with the
-		// filesystem directly and it is not aware about virtual folders
+		// if the user has virtual folders or file extensions filters we don't allow rsync since the rsync command
+		// interacts with the filesystem directly and it is not aware about virtual folders/extensions files filters
 		if len(c.connection.User.VirtualFolders) > 0 {
 		if len(c.connection.User.VirtualFolders) > 0 {
 			c.connection.Log(logger.LevelDebug, logSenderSSH, "user %#v has virtual folders, rsync is not supported",
 			c.connection.Log(logger.LevelDebug, logSenderSSH, "user %#v has virtual folders, rsync is not supported",
 				c.connection.User.Username)
 				c.connection.User.Username)
 			return command, errUnsupportedConfig
 			return command, errUnsupportedConfig
 		}
 		}
+		if len(c.connection.User.Filters.FileExtensions) > 0 {
+			c.connection.Log(logger.LevelDebug, logSenderSSH, "user %#v has file extensions filter, rsync is not supported",
+				c.connection.User.Username)
+			return command, errUnsupportedConfig
+		}
 		// we cannot avoid that rsync create symlinks so if the user has the permission
 		// we cannot avoid that rsync create symlinks so if the user has the permission
 		// to create symlinks we add the option --safe-links to the received rsync command if
 		// to create symlinks we add the option --safe-links to the received rsync command if
 		// it is not already set. This should prevent to create symlinks that point outside
 		// it is not already set. This should prevent to create symlinks that point outside

+ 30 - 0
templates/user.html

@@ -217,6 +217,36 @@
         </div>
         </div>
     </div>
     </div>
 
 
+    <div class="form-group row">
+        <label for="idFilesExtensionsDenied" class="col-sm-2 col-form-label">Denied file extensions</label>
+        <div class="col-sm-10">
+            <textarea class="form-control" id="idFilesExtensionsDenied" name="denied_extensions" rows="3"
+                aria-describedby="deniedExtensionsHelpBlock">{{range $index, $filter := .User.Filters.FileExtensions -}}
+                {{if $filter.DeniedExtensions -}}
+                {{$filter.Path}}::{{range $idx, $p := $filter.DeniedExtensions}}{{if $idx}},{{end}}{{$p}}{{end}}&#10;
+                {{- end}}
+                {{- end}}</textarea>
+            <small id="deniedExtensionsHelpBlock" class="form-text text-muted">
+                One directory per line as dir::extensions1,extensions2, for example /subdir::.zip,.rar
+            </small>
+        </div>
+    </div>
+
+    <div class="form-group row">
+        <label for="idFilesExtensionsAllowed" class="col-sm-2 col-form-label">Allowed file extensions</label>
+        <div class="col-sm-10">
+            <textarea class="form-control" id="idFilesExtensionsAllowed" name="allowed_extensions" rows="3"
+                aria-describedby="allowedExtensionsHelpBlock">{{range $index, $filter := .User.Filters.FileExtensions -}}
+                {{if $filter.AllowedExtensions -}}
+                {{$filter.Path}}::{{range $idx, $p := $filter.AllowedExtensions}}{{if $idx}},{{end}}{{$p}}{{end}}&#10;
+                {{- end}}
+                {{- end}}</textarea>
+            <small id="allowedExtensionsHelpBlock" class="form-text text-muted">
+                One directory per line as dir::extensions1,extensions2, for example /somedir::.jpg,.png
+            </small>
+        </div>
+    </div>
+
     <div class="form-group row">
     <div class="form-group row">
         <label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
         <label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
         <div class="col-sm-10">
         <div class="col-sm-10">