filters: we can now set allowed and denied files extensions
This commit is contained in:
parent
7163fde724
commit
b885d453a2
17 changed files with 826 additions and 59 deletions
|
@ -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 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 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.
|
||||
- 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.
|
||||
|
@ -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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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
|
||||
|
@ -742,6 +743,10 @@ For each account the following properties can be configured:
|
|||
- `publickey`
|
||||
- `password`
|
||||
- `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
|
||||
- `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`
|
||||
|
|
|
@ -677,6 +677,32 @@ func validatePublicKeys(user *User) error {
|
|||
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 {
|
||||
if len(user.Filters.AllowedIP) == 0 {
|
||||
user.Filters.AllowedIP = []string{}
|
||||
|
@ -707,6 +733,9 @@ func validateFilters(user *User) error {
|
|||
return &ValidationError{err: fmt.Sprintf("invalid login method: %#v", loginMethod)}
|
||||
}
|
||||
}
|
||||
if err := validateFiltersFileExtensions(user); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
|
@ -51,6 +52,29 @@ const (
|
|||
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
|
||||
type UserFilters struct {
|
||||
// only clients connecting from these IP/Mask are allowed.
|
||||
|
@ -63,6 +87,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"`
|
||||
// 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
|
||||
|
@ -230,6 +257,41 @@ func (u *User) IsLoginMethodAllowed(loginMetod string) bool {
|
|||
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.
|
||||
// If AllowedIP is defined only the specified IP/Mask can 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)
|
||||
filters.DeniedLoginMethods = make([]string, len(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{
|
||||
Provider: u.FsConfig.Provider,
|
||||
S3Config: vfs.S3FsConfig{
|
||||
|
|
|
@ -103,11 +103,13 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
user, err := dataprovider.GetUserByID(dataProvider, userID)
|
||||
currentPermissions := user.Permissions
|
||||
currentFileExtensions := user.Filters.FileExtensions
|
||||
currentS3AccessSecret := ""
|
||||
if user.FsConfig.Provider == 1 {
|
||||
currentS3AccessSecret = user.FsConfig.S3Config.AccessSecret
|
||||
}
|
||||
user.Permissions = make(map[string][]string)
|
||||
user.Filters.FileExtensions = []dataprovider.ExtensionsFilter{}
|
||||
if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
||||
sendAPIResponse(w, r, err, "", http.StatusNotFound)
|
||||
return
|
||||
|
@ -124,6 +126,10 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
|
|||
if len(user.Permissions) == 0 {
|
||||
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
|
||||
if user.FsConfig.Provider == 1 {
|
||||
if utils.RemoveDecryptionKey(currentS3AccessSecret) == user.FsConfig.S3Config.AccessSecret ||
|
||||
|
|
|
@ -544,6 +544,40 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User)
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -325,6 +325,45 @@ func TestAddUserInvalidFilters(t *testing.T) {
|
|||
if err != nil {
|
||||
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) {
|
||||
|
@ -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.DeniedIP = []string{"192.168.3.0/24", "192.168.4.0/24"}
|
||||
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.DownloadBandwidth = 512
|
||||
user.VirtualFolders = nil
|
||||
|
@ -1706,6 +1750,8 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
form.Set("permissions", "*")
|
||||
form.Set("sub_dirs_permissions", " /subdir::list ,download ")
|
||||
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, "", "")
|
||||
// test invalid url escape
|
||||
req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
|
||||
|
@ -1845,6 +1891,10 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
if !vfolderFoumd {
|
||||
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)
|
||||
rr = executeRequest(req)
|
||||
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("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ")
|
||||
form.Set("denied_ip", " 10.0.0.2/32 ")
|
||||
form.Set("denied_extensions", "/dir1::.zip")
|
||||
form.Set("ssh_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive)
|
||||
b, contentType, _ := getMultipartFormData(form, "", "")
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
|
||||
|
@ -1890,10 +1941,7 @@ func TestWebUserUpdateMock(t *testing.T) {
|
|||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
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 {
|
||||
t.Errorf("1 user is expected")
|
||||
}
|
||||
|
@ -1929,6 +1977,9 @@ func TestWebUserUpdateMock(t *testing.T) {
|
|||
if !utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, 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)
|
||||
rr = executeRequest(req)
|
||||
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_endpoint", user.FsConfig.S3Config.Endpoint)
|
||||
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, "", "")
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
@ -2020,6 +2073,9 @@ func TestWebUserS3Mock(t *testing.T) {
|
|||
if updateUser.FsConfig.S3Config.KeyPrefix != user.FsConfig.S3Config.KeyPrefix {
|
||||
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)
|
||||
rr = executeRequest(req)
|
||||
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_storage_class", user.FsConfig.GCSConfig.StorageClass)
|
||||
form.Set("gcs_key_prefix", user.FsConfig.GCSConfig.KeyPrefix)
|
||||
form.Set("allowed_extensions", "/dir1::.jpg,.png")
|
||||
b, contentType, _ := getMultipartFormData(form, "", "")
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
@ -2087,10 +2144,7 @@ func TestWebUserGCSMock(t *testing.T) {
|
|||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
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 {
|
||||
t.Errorf("1 user is expected")
|
||||
}
|
||||
|
@ -2110,6 +2164,9 @@ func TestWebUserGCSMock(t *testing.T) {
|
|||
if updateUser.FsConfig.GCSConfig.KeyPrefix != user.FsConfig.GCSConfig.KeyPrefix {
|
||||
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")
|
||||
b, contentType, _ = getMultipartFormData(form, "", "")
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), &b)
|
||||
|
|
|
@ -165,6 +165,53 @@ func TestCompareUserFilters(t *testing.T) {
|
|||
if err == nil {
|
||||
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) {
|
||||
|
|
|
@ -2,7 +2,7 @@ openapi: 3.0.1
|
|||
info:
|
||||
title: SFTPGo
|
||||
description: 'SFTPGo REST API'
|
||||
version: 1.8.2
|
||||
version: 1.8.3
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
@ -933,6 +933,26 @@ components:
|
|||
- 'publickey'
|
||||
- 'password'
|
||||
- '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:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -956,6 +976,12 @@ components:
|
|||
$ref: '#/components/schemas/LoginMethods'
|
||||
nullable: true
|
||||
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
|
||||
S3Config:
|
||||
type: object
|
||||
|
|
65
httpd/web.go
65
httpd/web.go
|
@ -7,6 +7,7 @@ import (
|
|||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -241,11 +242,75 @@ func getSliceFromDelimitedValues(values, delimiter string) []string {
|
|||
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 {
|
||||
var filters dataprovider.UserFilters
|
||||
filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
|
||||
filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
|
||||
filters.DeniedLoginMethods = r.Form["ssh_login_methods"]
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ Let's see a sample usage for each REST API.
|
|||
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:
|
||||
|
@ -73,6 +73,29 @@ Output:
|
|||
"denied_login_methods": [
|
||||
"password",
|
||||
"keyboard-interactive"
|
||||
],
|
||||
"file_extensions": [
|
||||
{
|
||||
"allowed_extensions": [
|
||||
".jpg",
|
||||
".png"
|
||||
],
|
||||
"path": "/dir1"
|
||||
},
|
||||
{
|
||||
"allowed_extensions": [
|
||||
".rar",
|
||||
".png"
|
||||
],
|
||||
"path": "/dir2"
|
||||
},
|
||||
{
|
||||
"denied_extensions": [
|
||||
".zip",
|
||||
".rar"
|
||||
],
|
||||
"path": "/dir3"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gid": 1000,
|
||||
|
@ -115,7 +138,7 @@ Output:
|
|||
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:
|
||||
|
|
|
@ -76,7 +76,8 @@ class SFTPGoApiRequests:
|
|||
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_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,
|
||||
'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files,
|
||||
'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth,
|
||||
|
@ -94,8 +95,9 @@ class SFTPGoApiRequests:
|
|||
user.update({'permissions':permissions})
|
||||
if 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,
|
||||
s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket,
|
||||
gcs_key_prefix, gcs_storage_class, gcs_credentials_file,
|
||||
|
@ -133,7 +135,7 @@ class SFTPGoApiRequests:
|
|||
permissions.update({directory:values})
|
||||
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 = {}
|
||||
if allowed_ip:
|
||||
if len(allowed_ip) == 1 and not allowed_ip[0]:
|
||||
|
@ -150,6 +152,54 @@ class SFTPGoApiRequests:
|
|||
filters.update({'denied_login_methods':[]})
|
||||
else:
|
||||
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
|
||||
|
||||
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='',
|
||||
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_login_methods=[], virtual_folders=[], denied_extensions=[], allowed_extensions=[]):
|
||||
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)
|
||||
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)
|
||||
self.printResponse(r)
|
||||
|
||||
|
@ -202,12 +253,14 @@ class SFTPGoApiRequests:
|
|||
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_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,
|
||||
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)
|
||||
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)
|
||||
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')
|
||||
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')
|
||||
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'],
|
||||
help='Filesystem provider. 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.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_login_methods, args.virtual_folders, args.denied_extensions, args.allowed_extensions)
|
||||
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,
|
||||
|
@ -605,7 +665,7 @@ if __name__ == '__main__':
|
|||
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.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':
|
||||
api.deleteUser(args.id)
|
||||
elif args.command == 'get-users':
|
||||
|
|
|
@ -51,6 +51,11 @@ func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) {
|
|||
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)
|
||||
if err != nil {
|
||||
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.
|
||||
func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
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")
|
||||
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)) {
|
||||
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)
|
||||
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()
|
||||
if err := c.fs.Remove(filePath, false); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to remove a file/symlink %#v: %+v", filePath, err)
|
||||
|
|
|
@ -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) {
|
||||
buf := make([]byte, 65535)
|
||||
stdErrBuf := make([]byte, 65535)
|
||||
|
|
45
sftpd/scp.go
45
sftpd/scp.go
|
@ -1,6 +1,7 @@
|
|||
package sftpd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
|
@ -19,10 +20,11 @@ import (
|
|||
)
|
||||
|
||||
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 {
|
||||
|
@ -124,10 +126,9 @@ func (c *scpCommand) handleCreateDir(dirPath string) error {
|
|||
return err
|
||||
}
|
||||
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.sendErrorMessage(err.Error())
|
||||
return err
|
||||
c.sendErrorMessage(errPermission.Error())
|
||||
return errPermission
|
||||
}
|
||||
|
||||
err = c.createDir(p)
|
||||
|
@ -243,6 +244,11 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
|
|||
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
if c.connection.fs.IsNotExist(statErr) {
|
||||
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.sendErrorMessage(err.Error())
|
||||
return err
|
||||
c.sendErrorMessage(errPermission.Error())
|
||||
return errPermission
|
||||
}
|
||||
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) {
|
||||
err := fmt.Errorf("Permission denied")
|
||||
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() {
|
||||
|
@ -461,20 +465,23 @@ func (c *scpCommand) handleDownload(filePath string) error {
|
|||
|
||||
if stat.IsDir() {
|
||||
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.sendErrorMessage(err.Error())
|
||||
return err
|
||||
c.sendErrorMessage(errPermission.Error())
|
||||
return errPermission
|
||||
}
|
||||
err = c.handleRecursiveDownload(p, stat)
|
||||
return err
|
||||
}
|
||||
|
||||
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.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)
|
||||
|
|
|
@ -2115,6 +2115,81 @@ func TestBandwidthAndConnections(t *testing.T) {
|
|||
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) {
|
||||
usePubKey := true
|
||||
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) {
|
||||
user := getTestUser(true)
|
||||
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) {
|
||||
if len(scpPath) == 0 {
|
||||
t.Skip("scp command not found, unable to execute this test")
|
||||
|
|
|
@ -129,6 +129,10 @@ func (c *sshCommand) handleHashCommands() error {
|
|||
response = fmt.Sprintf("%x -\n", h.Sum(nil))
|
||||
} else {
|
||||
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)
|
||||
if err != nil {
|
||||
return c.sendErrorResponse(err)
|
||||
|
@ -284,6 +288,41 @@ func (c *sshCommand) executeSystemCommand(command systemCommand) error {
|
|||
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) {
|
||||
command := systemCommand{
|
||||
cmd: nil,
|
||||
|
@ -303,31 +342,24 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) {
|
|||
args = append(args, path)
|
||||
}
|
||||
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 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 {
|
||||
c.connection.Log(logger.LevelDebug, logSenderSSH, "user %#v has virtual folders, rsync is not supported",
|
||||
c.connection.User.Username)
|
||||
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
|
||||
// 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
|
||||
|
|
|
@ -217,6 +217,36 @@
|
|||
</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}}
|
||||
{{- 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}}
|
||||
{{- 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">
|
||||
<label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
|
||||
<div class="col-sm-10">
|
||||
|
|
Loading…
Reference in a new issue