add support for limit files using shell like patterns

Fixes #209
This commit is contained in:
Nicola Murino 2020-11-15 22:04:48 +01:00
parent c0f47a58f2
commit a6355e298e
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
19 changed files with 706 additions and 265 deletions

View file

@ -28,7 +28,7 @@ It can serve local filesystem, S3 (compatible) Object Storage, Google Cloud Stor
- 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. - Per user and per directory shell like patterns filters are supported: files can be allowed or denied based on shell like patterns.
- 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, pre-delete, delete, rename, on SSH commands and on user add, update and delete. - Configurable custom commands and/or HTTP notifications on file upload, download, pre-delete, delete, rename, on SSH commands and on user add, update and delete.
- Automatically terminating idle connections. - Automatically terminating idle connections.

View file

@ -32,8 +32,8 @@ var (
portablePublicKeys []string portablePublicKeys []string
portablePermissions []string portablePermissions []string
portableSSHCommands []string portableSSHCommands []string
portableAllowedExtensions []string portableAllowedPatterns []string
portableDeniedExtensions []string portableDeniedPatterns []string
portableFsProvider int portableFsProvider int
portableS3Bucket string portableS3Bucket string
portableS3Region string portableS3Region string
@ -174,7 +174,7 @@ Please take a look at the usage below to customize the serving parameters`,
}, },
}, },
Filters: dataprovider.UserFilters{ Filters: dataprovider.UserFilters{
FileExtensions: parseFileExtensionsFilters(), FilePatterns: parsePatternsFilesFilters(),
}, },
}, },
} }
@ -217,16 +217,16 @@ value`)
portableCmd.Flags().StringSliceVarP(&portablePermissions, "permissions", "g", []string{"list", "download"}, portableCmd.Flags().StringSliceVarP(&portablePermissions, "permissions", "g", []string{"list", "download"},
`User's permissions. "*" means any `User's permissions. "*" means any
permission`) permission`)
portableCmd.Flags().StringArrayVar(&portableAllowedExtensions, "allowed-extensions", []string{}, portableCmd.Flags().StringArrayVar(&portableAllowedPatterns, "allowed-patterns", []string{},
`Allowed file extensions case `Allowed file patterns case insensitive.
insensitive. The format is The format is:
/dir::ext1,ext2. /dir::pattern1,pattern2.
For example: "/somedir::.jpg,.png"`) For example: "/somedir::*.jpg,a*b?.png"`)
portableCmd.Flags().StringArrayVar(&portableDeniedExtensions, "denied-extensions", []string{}, portableCmd.Flags().StringArrayVar(&portableDeniedPatterns, "denied-patterns", []string{},
`Denied file extensions case `Denied file patterns case insensitive.
insensitive. The format is The format is:
/dir::ext1,ext2. /dir::pattern1,pattern2.
For example: "/somedir::.jpg,.png"`) For example: "/somedir::*.jpg,a*b?.png"`)
portableCmd.Flags().BoolVarP(&portableAdvertiseService, "advertise-service", "S", false, portableCmd.Flags().BoolVarP(&portableAdvertiseService, "advertise-service", "S", false,
`Advertise SFTP/FTP service using `Advertise SFTP/FTP service using
multicast DNS`) multicast DNS`)
@ -287,42 +287,42 @@ parallel`)
rootCmd.AddCommand(portableCmd) rootCmd.AddCommand(portableCmd)
} }
func parseFileExtensionsFilters() []dataprovider.ExtensionsFilter { func parsePatternsFilesFilters() []dataprovider.PatternsFilter {
var extensions []dataprovider.ExtensionsFilter var patterns []dataprovider.PatternsFilter
for _, val := range portableAllowedExtensions { for _, val := range portableAllowedPatterns {
p, exts := getExtensionsFilterValues(strings.TrimSpace(val)) p, exts := getPatternsFilterValues(strings.TrimSpace(val))
if len(p) > 0 { if len(p) > 0 {
extensions = append(extensions, dataprovider.ExtensionsFilter{ patterns = append(patterns, dataprovider.PatternsFilter{
Path: path.Clean(p), Path: path.Clean(p),
AllowedExtensions: exts, AllowedPatterns: exts,
DeniedExtensions: []string{}, DeniedPatterns: []string{},
}) })
} }
} }
for _, val := range portableDeniedExtensions { for _, val := range portableDeniedPatterns {
p, exts := getExtensionsFilterValues(strings.TrimSpace(val)) p, exts := getPatternsFilterValues(strings.TrimSpace(val))
if len(p) > 0 { if len(p) > 0 {
found := false found := false
for index, e := range extensions { for index, e := range patterns {
if path.Clean(e.Path) == path.Clean(p) { if path.Clean(e.Path) == path.Clean(p) {
extensions[index].DeniedExtensions = append(extensions[index].DeniedExtensions, exts...) patterns[index].DeniedPatterns = append(patterns[index].DeniedPatterns, exts...)
found = true found = true
break break
} }
} }
if !found { if !found {
extensions = append(extensions, dataprovider.ExtensionsFilter{ patterns = append(patterns, dataprovider.PatternsFilter{
Path: path.Clean(p), Path: path.Clean(p),
AllowedExtensions: []string{}, AllowedPatterns: []string{},
DeniedExtensions: exts, DeniedPatterns: exts,
}) })
} }
} }
} }
return extensions return patterns
} }
func getExtensionsFilterValues(value string) (string, []string) { func getPatternsFilterValues(value string) (string, []string) {
if strings.Contains(value, "::") { if strings.Contains(value, "::") {
dirExts := strings.Split(value, "::") dirExts := strings.Split(value, "::")
if len(dirExts) > 1 { if len(dirExts) > 1 {
@ -334,7 +334,7 @@ func getExtensionsFilterValues(value string) (string, []string) {
exts = append(exts, cleanedExt) exts = append(exts, cleanedExt)
} }
} }
if len(dir) > 0 && len(exts) > 0 { if dir != "" && len(exts) > 0 {
return dir, exts return dir, exts
} }
} }

View file

@ -903,6 +903,50 @@ func validatePublicKeys(user *User) error {
return nil return nil
} }
func validateFiltersPatternExtensions(user *User) error {
if len(user.Filters.FilePatterns) == 0 {
user.Filters.FilePatterns = []PatternsFilter{}
return nil
}
filteredPaths := []string{}
var filters []PatternsFilter
for _, f := range user.Filters.FilePatterns {
cleanedPath := filepath.ToSlash(path.Clean(f.Path))
if !path.IsAbs(cleanedPath) {
return &ValidationError{err: fmt.Sprintf("invalid path %#v for file patterns filter", f.Path)}
}
if utils.IsStringInSlice(cleanedPath, filteredPaths) {
return &ValidationError{err: fmt.Sprintf("duplicate file patterns filter for path %#v", f.Path)}
}
if len(f.AllowedPatterns) == 0 && len(f.DeniedPatterns) == 0 {
return &ValidationError{err: fmt.Sprintf("empty file patterns filter for path %#v", f.Path)}
}
f.Path = cleanedPath
allowed := make([]string, 0, len(f.AllowedPatterns))
denied := make([]string, 0, len(f.DeniedPatterns))
for _, pattern := range f.AllowedPatterns {
_, err := path.Match(pattern, "abc")
if err != nil {
return &ValidationError{err: fmt.Sprintf("invalid file pattern filter %v", pattern)}
}
allowed = append(allowed, strings.ToLower(pattern))
}
for _, pattern := range f.DeniedPatterns {
_, err := path.Match(pattern, "abc")
if err != nil {
return &ValidationError{err: fmt.Sprintf("invalid file pattern filter %v", pattern)}
}
denied = append(denied, strings.ToLower(pattern))
}
f.AllowedPatterns = allowed
f.DeniedPatterns = denied
filters = append(filters, f)
filteredPaths = append(filteredPaths, cleanedPath)
}
user.Filters.FilePatterns = filters
return nil
}
func validateFiltersFileExtensions(user *User) error { func validateFiltersFileExtensions(user *User) error {
if len(user.Filters.FileExtensions) == 0 { if len(user.Filters.FileExtensions) == 0 {
user.Filters.FileExtensions = []ExtensionsFilter{} user.Filters.FileExtensions = []ExtensionsFilter{}
@ -922,6 +966,16 @@ func validateFiltersFileExtensions(user *User) error {
return &ValidationError{err: fmt.Sprintf("empty file extensions filter for path %#v", f.Path)} return &ValidationError{err: fmt.Sprintf("empty file extensions filter for path %#v", f.Path)}
} }
f.Path = cleanedPath f.Path = cleanedPath
allowed := make([]string, 0, len(f.AllowedExtensions))
denied := make([]string, 0, len(f.DeniedExtensions))
for _, ext := range f.AllowedExtensions {
allowed = append(allowed, strings.ToLower(ext))
}
for _, ext := range f.DeniedExtensions {
denied = append(denied, strings.ToLower(ext))
}
f.AllowedExtensions = allowed
f.DeniedExtensions = denied
filters = append(filters, f) filters = append(filters, f)
filteredPaths = append(filteredPaths, cleanedPath) filteredPaths = append(filteredPaths, cleanedPath)
} }
@ -929,6 +983,13 @@ func validateFiltersFileExtensions(user *User) error {
return nil return nil
} }
func validateFileFilters(user *User) error {
if err := validateFiltersFileExtensions(user); err != nil {
return err
}
return validateFiltersPatternExtensions(user)
}
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{}
@ -970,7 +1031,7 @@ func validateFilters(user *User) error {
return &ValidationError{err: fmt.Sprintf("invalid protocol: %#v", p)} return &ValidationError{err: fmt.Sprintf("invalid protocol: %#v", p)}
} }
} }
return validateFiltersFileExtensions(user) return validateFileFilters(user)
} }
func saveGCSCredentials(user *User) error { func saveGCSCredentials(user *User) error {

View file

@ -81,25 +81,46 @@ func (c *CachedUser) IsExpired() bool {
// ExtensionsFilter defines filters based on file extensions. // ExtensionsFilter defines filters based on file extensions.
// These restrictions do not apply to files listing for performance reasons, so // These restrictions do not apply to files listing for performance reasons, so
// a denied file cannot be downloaded/overwritten/renamed but will still be // a denied file cannot be downloaded/overwritten/renamed but will still be
// it will still be listed in the list of files. // in the list of files.
// System commands such as Git and rsync interacts with the filesystem directly // System commands such as Git and rsync interacts with the filesystem directly
// and they are not aware about these restrictions so they are not allowed // and they are not aware about these restrictions so they are not allowed
// inside paths with extensions filters // inside paths with extensions filters
type ExtensionsFilter struct { type ExtensionsFilter struct {
// SFTP/SCP path, if no other specific filter is defined, the filter apply for // Virtual path, if no other specific filter is defined, the filter apply for
// sub directories too. // sub directories too.
// For example if filters are defined for the paths "/" and "/sub" then the // For example if filters are defined for the paths "/" and "/sub" then the
// filters for "/" are applied for any file outside the "/sub" directory // filters for "/" are applied for any file outside the "/sub" directory
Path string `json:"path"` Path string `json:"path"`
// only files with these, case insensitive, extensions are allowed. // only files with these, case insensitive, extensions are allowed.
// Shell like expansion is not supported so you have to specify ".jpg" and // Shell like expansion is not supported so you have to specify ".jpg" and
// not "*.jpg" // not "*.jpg". If you want shell like patterns use pattern filters
AllowedExtensions []string `json:"allowed_extensions,omitempty"` AllowedExtensions []string `json:"allowed_extensions,omitempty"`
// files with these, case insensitive, extensions are not allowed. // files with these, case insensitive, extensions are not allowed.
// Denied file extensions are evaluated before the allowed ones // Denied file extensions are evaluated before the allowed ones
DeniedExtensions []string `json:"denied_extensions,omitempty"` DeniedExtensions []string `json:"denied_extensions,omitempty"`
} }
// PatternsFilter defines filters based on shell like patterns.
// These restrictions do not apply to files listing for performance reasons, so
// a denied file cannot be downloaded/overwritten/renamed but will still be
// 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 they are not allowed
// inside paths with extensions filters
type PatternsFilter struct {
// Virtual 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"`
// files with these, case insensitive, patterns are allowed.
// Denied file patterns are evaluated before the allowed ones
AllowedPatterns []string `json:"allowed_patterns,omitempty"`
// files with these, case insensitive, patterns are not allowed.
// Denied file patterns are evaluated before the allowed ones
DeniedPatterns []string `json:"denied_patterns,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.
@ -118,6 +139,8 @@ type UserFilters struct {
// filters based on file extensions. // filters based on file extensions.
// Please note that these restrictions can be easily bypassed. // Please note that these restrictions can be easily bypassed.
FileExtensions []ExtensionsFilter `json:"file_extensions,omitempty"` FileExtensions []ExtensionsFilter `json:"file_extensions,omitempty"`
// filter based on shell patterns
FilePatterns []PatternsFilter `json:"file_patterns,omitempty"`
// max size allowed for a single upload, 0 means unlimited // max size allowed for a single upload, 0 means unlimited
MaxUploadFileSize int64 `json:"max_upload_file_size,omitempty"` MaxUploadFileSize int64 `json:"max_upload_file_size,omitempty"`
} }
@ -444,11 +467,15 @@ func (u *User) GetAllowedLoginMethods() []string {
} }
// IsFileAllowed returns true if the specified file is allowed by the file restrictions filters // IsFileAllowed returns true if the specified file is allowed by the file restrictions filters
func (u *User) IsFileAllowed(sftpPath string) bool { func (u *User) IsFileAllowed(virtualPath string) bool {
return u.isFilePatternAllowed(virtualPath) && u.isFileExtensionAllowed(virtualPath)
}
func (u *User) isFileExtensionAllowed(virtualPath string) bool {
if len(u.Filters.FileExtensions) == 0 { if len(u.Filters.FileExtensions) == 0 {
return true return true
} }
dirsForPath := utils.GetDirsForSFTPPath(path.Dir(sftpPath)) dirsForPath := utils.GetDirsForSFTPPath(path.Dir(virtualPath))
var filter ExtensionsFilter var filter ExtensionsFilter
for _, dir := range dirsForPath { for _, dir := range dirsForPath {
for _, f := range u.Filters.FileExtensions { for _, f := range u.Filters.FileExtensions {
@ -457,12 +484,12 @@ func (u *User) IsFileAllowed(sftpPath string) bool {
break break
} }
} }
if len(filter.Path) > 0 { if filter.Path != "" {
break break
} }
} }
if len(filter.Path) > 0 { if filter.Path != "" {
toMatch := strings.ToLower(sftpPath) toMatch := strings.ToLower(virtualPath)
for _, denied := range filter.DeniedExtensions { for _, denied := range filter.DeniedExtensions {
if strings.HasSuffix(toMatch, denied) { if strings.HasSuffix(toMatch, denied) {
return false return false
@ -478,6 +505,42 @@ func (u *User) IsFileAllowed(sftpPath string) bool {
return true return true
} }
func (u *User) isFilePatternAllowed(virtualPath string) bool {
if len(u.Filters.FilePatterns) == 0 {
return true
}
dirsForPath := utils.GetDirsForSFTPPath(path.Dir(virtualPath))
var filter PatternsFilter
for _, dir := range dirsForPath {
for _, f := range u.Filters.FilePatterns {
if f.Path == dir {
filter = f
break
}
}
if filter.Path != "" {
break
}
}
if filter.Path != "" {
toMatch := strings.ToLower(path.Base(virtualPath))
for _, denied := range filter.DeniedPatterns {
matched, err := path.Match(denied, toMatch)
if err != nil || matched {
return false
}
}
for _, allowed := range filter.AllowedPatterns {
matched, err := path.Match(allowed, toMatch)
if err == nil && matched {
return true
}
}
return len(filter.AllowedPatterns) == 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.
@ -711,6 +774,8 @@ func (u *User) getACopy() User {
copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods) copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods)
filters.FileExtensions = make([]ExtensionsFilter, len(u.Filters.FileExtensions)) filters.FileExtensions = make([]ExtensionsFilter, len(u.Filters.FileExtensions))
copy(filters.FileExtensions, u.Filters.FileExtensions) copy(filters.FileExtensions, u.Filters.FileExtensions)
filters.FilePatterns = make([]PatternsFilter, len(u.Filters.FilePatterns))
copy(filters.FilePatterns, u.Filters.FilePatterns)
filters.DeniedProtocols = make([]string, len(u.Filters.DeniedProtocols)) filters.DeniedProtocols = make([]string, len(u.Filters.DeniedProtocols))
copy(filters.DeniedProtocols, u.Filters.DeniedProtocols) copy(filters.DeniedProtocols, u.Filters.DeniedProtocols)
fsConfig := Filesystem{ fsConfig := Filesystem{

View file

@ -41,10 +41,14 @@ For each account, the following properties can be configured:
- `SSH` - `SSH`
- `FTP` - `FTP`
- `DAV` - `DAV`
- `file_extensions`, list of struct. These restrictions do not apply to files listing for performance reasons, so a denied file cannot be downloaded/overwritten/renamed but it will still be listed in the list of files. Please note that these restrictions can be easily bypassed. Each struct contains the following fields: - `file_extensions`, list of struct. Deprecated, please use `file_patterns`. 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 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 - `allowed_extensions`, list of, case insensitive, allowed file extensions. 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 - `denied_extensions`, list of, case insensitive, denied file extensions. 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 - `path`, exposed virtual 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
- `file_patterns`, 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 in the list of files. Please note that these restrictions can be easily bypassed. For syntax details take a look [here](https://golang.org/pkg/path/#Match). Each struct contains the following fields:
- `allowed_patterns`, list of, case insensitive, allowed file patterns. Examples: `*.jpg`, `a*b?.png`. Any non matching file will be denied
- `denied_patterns`, list of, case insensitive, denied file patterns. Denied file patterns are evaluated before the allowed ones
- `path`, exposed virtual 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 (0), S3 Compatible Object Storage (1), Google Cloud Storage (2) and Azure Blob Storage (3) are supported - `fs_provider`, filesystem to serve via SFTP. Local filesystem (0), S3 Compatible Object Storage (1), Google Cloud Storage (2) and Azure Blob Storage (3) 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`

View file

@ -15,90 +15,92 @@ Usage:
sftpgo portable [flags] sftpgo portable [flags]
Flags: Flags:
-C, --advertise-credentials If the SFTP/FTP service is -C, --advertise-credentials If the SFTP/FTP service is
advertised via multicast DNS, this advertised via multicast DNS, this
flag allows to put username/password flag allows to put username/password
inside the advertised TXT record inside the advertised TXT record
-S, --advertise-service Advertise SFTP/FTP service using -S, --advertise-service Advertise SFTP/FTP service using
multicast DNS multicast DNS
--allowed-extensions stringArray Allowed file extensions case --allowed-patterns stringArray Allowed file patterns case insensitive.
insensitive. The format is The format is:
/dir::ext1,ext2. /dir::pattern1,pattern2.
For example: "/somedir::.jpg,.png" For example: "/somedir::*.jpg,a*b?.png"
--az-access-tier string Leave empty to use the default
container setting
--az-account-key string --az-account-key string
--az-account-name string --az-account-name string
--az-container string --az-container string
--az-endpoint string Leave empty to use the default: --az-endpoint string Leave empty to use the default:
"blob.core.windows.net" "blob.core.windows.net"
--az-key-prefix string Allows to restrict access to the --az-key-prefix string Allows to restrict access to the
virtual folder identified by this virtual folder identified by this
prefix and its contents prefix and its contents
--az-sas-url string Shared access signature URL --az-sas-url string Shared access signature URL
--az-upload-concurrency int How many parts are uploaded in --az-upload-concurrency int How many parts are uploaded in
parallel (default 2) parallel (default 2)
--az-upload-part-size int The buffer size for multipart uploads --az-upload-part-size int The buffer size for multipart uploads
(MB) (default 4) (MB) (default 4)
--az-use-emulator --az-use-emulator
--denied-extensions stringArray Denied file extensions case --denied-patterns stringArray Denied file patterns case insensitive.
insensitive. The format is The format is:
/dir::ext1,ext2. /dir::pattern1,pattern2.
For example: "/somedir::.jpg,.png" For example: "/somedir::*.jpg,a*b?.png"
-d, --directory string Path to the directory to serve. -d, --directory string Path to the directory to serve.
This can be an absolute path or a path This can be an absolute path or a path
relative to the current directory relative to the current directory
(default ".") (default ".")
-f, --fs-provider int 0 => local filesystem -f, --fs-provider int 0 => local filesystem
1 => AWS S3 compatible 1 => AWS S3 compatible
2 => Google Cloud Storage 2 => Google Cloud Storage
3 => Azure Blob Storage 3 => Azure Blob Storage
--ftpd-cert string Path to the certificate file for FTPS --ftpd-cert string Path to the certificate file for FTPS
--ftpd-key string Path to the key file for FTPS --ftpd-key string Path to the key file for FTPS
--ftpd-port int 0 means a random unprivileged port, --ftpd-port int 0 means a random unprivileged port,
< 0 disabled (default -1) < 0 disabled (default -1)
--gcs-automatic-credentials int 0 means explicit credentials using --gcs-automatic-credentials int 0 means explicit credentials using
a JSON credentials file, 1 automatic a JSON credentials file, 1 automatic
(default 1) (default 1)
--gcs-bucket string --gcs-bucket string
--gcs-credentials-file string Google Cloud Storage JSON credentials --gcs-credentials-file string Google Cloud Storage JSON credentials
file file
--gcs-key-prefix string Allows to restrict access to the --gcs-key-prefix string Allows to restrict access to the
virtual folder identified by this virtual folder identified by this
prefix and its contents prefix and its contents
--gcs-storage-class string --gcs-storage-class string
-h, --help help for portable -h, --help help for portable
-l, --log-file-path string Leave empty to disable logging -l, --log-file-path string Leave empty to disable logging
-v, --log-verbose Enable verbose logs -v, --log-verbose Enable verbose logs
-p, --password string Leave empty to use an auto generated -p, --password string Leave empty to use an auto generated
value value
-g, --permissions strings User's permissions. "*" means any -g, --permissions strings User's permissions. "*" means any
permission (default [list,download]) permission (default [list,download])
-k, --public-key strings -k, --public-key strings
--s3-access-key string --s3-access-key string
--s3-access-secret string --s3-access-secret string
--s3-bucket string --s3-bucket string
--s3-endpoint string --s3-endpoint string
--s3-key-prefix string Allows to restrict access to the --s3-key-prefix string Allows to restrict access to the
virtual folder identified by this virtual folder identified by this
prefix and its contents prefix and its contents
--s3-region string --s3-region string
--s3-storage-class string --s3-storage-class string
--s3-upload-concurrency int How many parts are uploaded in --s3-upload-concurrency int How many parts are uploaded in
parallel (default 2) parallel (default 2)
--s3-upload-part-size int The buffer size for multipart uploads --s3-upload-part-size int The buffer size for multipart uploads
(MB) (default 5) (MB) (default 5)
-s, --sftpd-port int 0 means a random unprivileged port -s, --sftpd-port int 0 means a random unprivileged port
-c, --ssh-commands strings SSH commands to enable. -c, --ssh-commands strings SSH commands to enable.
"*" means any supported SSH command "*" means any supported SSH command
including scp including scp
(default [md5sum,sha1sum,cd,pwd,scp]) (default [md5sum,sha1sum,cd,pwd,scp])
-u, --username string Leave empty to use an auto generated -u, --username string Leave empty to use an auto generated
value value
--webdav-cert string Path to the certificate file for WebDAV --webdav-cert string Path to the certificate file for WebDAV
over HTTPS over HTTPS
--webdav-key string Path to the key file for WebDAV over --webdav-key string Path to the key file for WebDAV over
HTTPS HTTPS
--webdav-port int 0 means a random unprivileged port, --webdav-port int 0 means a random unprivileged port,
< 0 disabled (default -1) < 0 disabled (default -1)
``` ```
In portable mode, SFTPGo can advertise the SFTP/FTP services and, optionally, the credentials via multicast DNS, so there is a standard way to discover the service and to automatically connect to it. In portable mode, SFTPGo can advertise the SFTP/FTP services and, optionally, the credentials via multicast DNS, so there is a standard way to discover the service and to automatically connect to it.

View file

@ -44,7 +44,7 @@ Let's see a sample usage for each REST API.
Command: Command:
```console ```console
python sftpgo_api_cli add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1::list,download" "/dir2::*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --s3-upload-part-size 10 --s3-upload-concurrency 4 --denied-login-methods "password" "keyboard-interactive" --allowed-extensions "/dir1::.jpg,.png" "/dir2::.rar,.png" --denied-extensions "/dir3::.zip,.rar" --denied-protocols DAV FTP python sftpgo_api_cli add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1::list,download" "/dir2::*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --s3-upload-part-size 10 --s3-upload-concurrency 4 --denied-login-methods "password" "keyboard-interactive" --allowed-patterns "/dir1::*.jpg,*.png" "/dir2::*.rar,*.png" --denied-patterns "/dir3::*.zip,*.rar" --denied-protocols DAV FTP
``` ```
Output: Output:
@ -80,25 +80,25 @@ Output:
"DAV", "DAV",
"FTP" "FTP"
], ],
"file_extensions": [ "file_patterns": [
{ {
"allowed_extensions": [ "allowed_patterns": [
".jpg", "*.jpg",
".png" "*.png"
], ],
"path": "/dir1" "path": "/dir1"
}, },
{ {
"allowed_extensions": [ "allowed_patterns": [
".rar", "*.rar",
".png" "*.png"
], ],
"path": "/dir2" "path": "/dir2"
}, },
{ {
"denied_extensions": [ "denied_patterns": [
".zip", "*.zip",
".rar" "*.rar"
], ],
"path": "/dir3" "path": "/dir3"
} }
@ -144,7 +144,7 @@ Output:
Command: Command:
```console ```console
python sftpgo_api_cli update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1::list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1::-1::-1" "/vdir2::/tmp/mapped2::100::104857600" --allowed-extensions "" --denied-extensions "" --max-upload-file-size 104857600 --denied-protocols "" python sftpgo_api_cli update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1::list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1::-1::-1" "/vdir2::/tmp/mapped2::100::104857600" --allowed-patterns "" --denied-patterns "" --max-upload-file-size 104857600 --denied-protocols ""
``` ```
Output: Output:
@ -182,29 +182,6 @@ Output:
"denied_ip": [ "denied_ip": [
"192.168.1.0/24" "192.168.1.0/24"
], ],
"file_extensions": [
{
"allowed_extensions": [
".jpg",
".png"
],
"path": "/dir1"
},
{
"allowed_extensions": [
".rar",
".png"
],
"path": "/dir2"
},
{
"denied_extensions": [
".zip",
".rar"
],
"path": "/dir3"
}
],
"max_upload_file_size": 104857600 "max_upload_file_size": 104857600
}, },
"gid": 33, "gid": 33,
@ -279,7 +256,8 @@ Output:
"filters": { "filters": {
"denied_ip": [ "denied_ip": [
"192.168.1.0/24" "192.168.1.0/24"
] ],
"max_upload_file_size": 104857600
}, },
"gid": 33, "gid": 33,
"home_dir": "/tmp/test_home_dir", "home_dir": "/tmp/test_home_dir",

View file

@ -81,7 +81,7 @@ class SFTPGoApiRequests:
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=[], s3_upload_part_size=0, s3_upload_concurrency=0, denied_patterns=[], allowed_patterns=[], s3_upload_part_size=0, s3_upload_concurrency=0,
max_upload_file_size=0, denied_protocols=[], az_container='', az_account_name='', az_account_key='', max_upload_file_size=0, denied_protocols=[], az_container='', az_account_name='', az_account_key='',
az_sas_url='', az_endpoint='', az_upload_part_size=0, az_upload_concurrency=0, az_key_prefix='', az_sas_url='', az_endpoint='', az_upload_part_size=0, az_upload_concurrency=0, az_key_prefix='',
az_use_emulator=False, az_access_tier=''): az_use_emulator=False, az_access_tier=''):
@ -103,8 +103,8 @@ class SFTPGoApiRequests:
if virtual_folders: if virtual_folders:
user.update({'virtual_folders':self.buildVirtualFolders(virtual_folders)}) user.update({'virtual_folders':self.buildVirtualFolders(virtual_folders)})
user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods, denied_extensions, user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods, denied_patterns,
allowed_extensions, max_upload_file_size, denied_protocols)}) allowed_patterns, max_upload_file_size, denied_protocols)})
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,
@ -158,7 +158,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, denied_extensions, allowed_extensions, def buildFilters(self, allowed_ip, denied_ip, denied_login_methods, denied_patterns, allowed_patterns,
max_upload_file_size, denied_protocols): max_upload_file_size, denied_protocols):
filters = {"max_upload_file_size":max_upload_file_size} filters = {"max_upload_file_size":max_upload_file_size}
if allowed_ip: if allowed_ip:
@ -181,11 +181,11 @@ class SFTPGoApiRequests:
filters.update({'denied_protocols':[]}) filters.update({'denied_protocols':[]})
else: else:
filters.update({'denied_protocols':denied_protocols}) filters.update({'denied_protocols':denied_protocols})
extensions_filter = [] patterns_filter = []
extensions_denied = [] patterns_denied = []
extensions_allowed = [] patterns_allowed = []
if denied_extensions: if denied_patterns:
for e in denied_extensions: for e in denied_patterns:
if '::' in e: if '::' in e:
directory = None directory = None
values = [] values = []
@ -195,10 +195,10 @@ class SFTPGoApiRequests:
else: else:
values = [v.strip() for v in value.split(',') if v.strip()] values = [v.strip() for v in value.split(',') if v.strip()]
if directory: if directory:
extensions_denied.append({'path':directory, 'denied_extensions':values, patterns_denied.append({'path':directory, 'denied_patterns':values,
'allowed_extensions':[]}) 'allowed_patterns':[]})
if allowed_extensions: if allowed_patterns:
for e in allowed_extensions: for e in allowed_patterns:
if '::' in e: if '::' in e:
directory = None directory = None
values = [] values = []
@ -208,27 +208,27 @@ class SFTPGoApiRequests:
else: else:
values = [v.strip() for v in value.split(',') if v.strip()] values = [v.strip() for v in value.split(',') if v.strip()]
if directory: if directory:
extensions_allowed.append({'path':directory, 'allowed_extensions':values, patterns_allowed.append({'path':directory, 'allowed_patterns':values,
'denied_extensions':[]}) 'denied_patterns':[]})
if extensions_allowed and extensions_denied: if patterns_allowed and patterns_denied:
for allowed in extensions_allowed: for allowed in patterns_allowed:
for denied in extensions_denied: for denied in patterns_denied:
if allowed.get('path') == denied.get('path'): if allowed.get('path') == denied.get('path'):
allowed.update({'denied_extensions':denied.get('denied_extensions')}) allowed.update({'denied_patterns':denied.get('denied_patterns')})
extensions_filter.append(allowed) patterns_filter.append(allowed)
for denied in extensions_denied: for denied in patterns_denied:
found = False found = False
for allowed in extensions_allowed: for allowed in patterns_allowed:
if allowed.get('path') == denied.get('path'): if allowed.get('path') == denied.get('path'):
found = True found = True
if not found: if not found:
extensions_filter.append(denied) patterns_filter.append(denied)
elif extensions_allowed: elif patterns_allowed:
extensions_filter = extensions_allowed patterns_filter = patterns_allowed
elif extensions_denied: elif patterns_denied:
extensions_filter = extensions_denied patterns_filter = patterns_denied
if allowed_extensions or denied_extensions: if allowed_patterns or denied_patterns:
filters.update({'file_extensions':extensions_filter}) filters.update({'file_patterns':patterns_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,
@ -275,7 +275,7 @@ 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_extensions=[], allowed_extensions=[], denied_login_methods=[], virtual_folders=[], denied_patterns=[], allowed_patterns=[],
s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, denied_protocols=[], az_container="", s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, denied_protocols=[], az_container="",
az_account_name='', az_account_key='', az_sas_url='', az_endpoint='', az_upload_part_size=0, az_account_name='', az_account_key='', az_sas_url='', az_endpoint='', az_upload_part_size=0,
az_upload_concurrency=0, az_key_prefix='', az_use_emulator=False, az_access_tier=''): az_upload_concurrency=0, az_key_prefix='', az_use_emulator=False, az_access_tier=''):
@ -283,8 +283,8 @@ class SFTPGoApiRequests:
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, denied_extensions, gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_patterns,
allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols, allowed_patterns, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols,
az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size, az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size,
az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier) az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier)
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)
@ -295,8 +295,8 @@ 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=[], denied_extensions=[], gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[], denied_patterns=[],
allowed_extensions=[], s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0, allowed_patterns=[], s3_upload_part_size=0, s3_upload_concurrency=0, max_upload_file_size=0,
denied_protocols=[], disconnect=0, az_container='', az_account_name='', az_account_key='', az_sas_url='', denied_protocols=[], disconnect=0, az_container='', az_account_name='', az_account_key='', az_sas_url='',
az_endpoint='', az_upload_part_size=0, az_upload_concurrency=0, az_key_prefix='', az_use_emulator=False, az_endpoint='', az_upload_part_size=0, az_upload_concurrency=0, az_key_prefix='', az_use_emulator=False,
az_access_tier=''): az_access_tier=''):
@ -304,8 +304,8 @@ class SFTPGoApiRequests:
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, denied_extensions, gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders, denied_patterns,
allowed_extensions, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols, allowed_patterns, s3_upload_part_size, s3_upload_concurrency, max_upload_file_size, denied_protocols,
az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size, az_container, az_account_name, az_account_key, az_sas_url, az_endpoint, az_upload_part_size,
az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier) az_upload_concurrency, az_key_prefix, az_use_emulator, az_access_tier)
r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), params={'disconnect':disconnect}, r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), params={'disconnect':disconnect},
@ -607,12 +607,12 @@ 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. ' parser.add_argument('--denied-patterns', type=str, nargs='*', default=[], help='Denied file patterns case insensitive. '
+'The format is /dir::ext1,ext2. For example: "/somedir::.jpg,.png" "/otherdir/subdir::.zip,.rar". ' + +'The format is /dir::pattern1,pattern2. For example: "/somedir::*.jpg,*.png" "/otherdir/subdir::a*b?.zip,*.rar". ' +
'You have to set both denied and allowed extensions to update existing values or none to preserve them.' + 'You have to set both denied and allowed patterns 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') ' If you only set allowed or denied patterns 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. ' parser.add_argument('--allowed-patterns', type=str, nargs='*', default=[], help='Allowed file patterns case insensitive. '
+'The format is /dir::ext1,ext2. For example: "/somedir::.jpg,.png" "/otherdir/subdir::.zip,.rar". ' + +'The format is /dir::pattern1,pattern2. For example: "/somedir::*.jpg,a*b?.png" "/otherdir/subdir::*.zip,*.rar". ' +
'Default: %(default)s') 'Default: %(default)s')
parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3', 'GCS', "AzureBlob"], parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3', 'GCS', "AzureBlob"],
help='Filesystem provider. Default: %(default)s') help='Filesystem provider. Default: %(default)s')
@ -804,7 +804,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_extensions, args.allowed_extensions, args.denied_login_methods, args.virtual_folders, args.denied_patterns, args.allowed_patterns,
args.s3_upload_part_size, args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols, args.s3_upload_part_size, args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols,
args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint, args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint,
args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator, args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator,
@ -817,7 +817,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.denied_extensions, args.allowed_extensions, args.s3_upload_part_size, args.virtual_folders, args.denied_patterns, args.allowed_patterns, args.s3_upload_part_size,
args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols, args.disconnect, args.s3_upload_concurrency, args.max_upload_file_size, args.denied_protocols, args.disconnect,
args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint, args.az_container, args.az_account_name, args.az_account_key, args.az_sas_url, args.az_endpoint,
args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator, args.az_upload_part_size, args.az_upload_concurrency, args.az_key_prefix, args.az_use_emulator,

View file

@ -519,12 +519,20 @@ func TestDownloadErrors(t *testing.T) {
DeniedExtensions: []string{".zip"}, DeniedExtensions: []string{".zip"},
}, },
} }
u.Filters.FilePatterns = []dataprovider.PatternsFilter{
{
Path: "/sub2",
AllowedPatterns: []string{},
DeniedPatterns: []string{"*.jpg"},
},
}
user, _, err := httpd.AddUser(u, http.StatusOK) user, _, err := httpd.AddUser(u, http.StatusOK)
assert.NoError(t, err) assert.NoError(t, err)
client, err := getFTPClient(user, true) client, err := getFTPClient(user, true)
if assert.NoError(t, err) { if assert.NoError(t, err) {
testFilePath1 := filepath.Join(user.HomeDir, subDir1, "file.zip") testFilePath1 := filepath.Join(user.HomeDir, subDir1, "file.zip")
testFilePath2 := filepath.Join(user.HomeDir, subDir2, "file.zip") testFilePath2 := filepath.Join(user.HomeDir, subDir2, "file.zip")
testFilePath3 := filepath.Join(user.HomeDir, subDir2, "file.jpg")
err = os.MkdirAll(filepath.Dir(testFilePath1), os.ModePerm) err = os.MkdirAll(filepath.Dir(testFilePath1), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
err = os.MkdirAll(filepath.Dir(testFilePath2), os.ModePerm) err = os.MkdirAll(filepath.Dir(testFilePath2), os.ModePerm)
@ -533,11 +541,15 @@ func TestDownloadErrors(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
err = ioutil.WriteFile(testFilePath2, []byte("file2"), os.ModePerm) err = ioutil.WriteFile(testFilePath2, []byte("file2"), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
err = ioutil.WriteFile(testFilePath3, []byte("file3"), os.ModePerm)
assert.NoError(t, err)
localDownloadPath := filepath.Join(homeBasePath, testDLFileName) localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
err = ftpDownloadFile(path.Join("/", subDir1, "file.zip"), localDownloadPath, 5, client, 0) err = ftpDownloadFile(path.Join("/", subDir1, "file.zip"), localDownloadPath, 5, client, 0)
assert.Error(t, err) assert.Error(t, err)
err = ftpDownloadFile(path.Join("/", subDir2, "file.zip"), localDownloadPath, 5, client, 0) err = ftpDownloadFile(path.Join("/", subDir2, "file.zip"), localDownloadPath, 5, client, 0)
assert.Error(t, err) assert.Error(t, err)
err = ftpDownloadFile(path.Join("/", subDir2, "file.jpg"), localDownloadPath, 5, client, 0)
assert.Error(t, err)
err = ftpDownloadFile("/missing.zip", localDownloadPath, 5, client, 0) err = ftpDownloadFile("/missing.zip", localDownloadPath, 5, client, 0)
assert.Error(t, err) assert.Error(t, err)
err = client.Quit() err = client.Quit()

View file

@ -774,6 +774,40 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User)
if err := compareUserFileExtensionsFilters(expected, actual); err != nil { if err := compareUserFileExtensionsFilters(expected, actual); err != nil {
return err return err
} }
return compareUserFilePatternsFilters(expected, actual)
}
func checkFilterMatch(expected []string, actual []string) bool {
if len(expected) != len(actual) {
return false
}
for _, e := range expected {
if !utils.IsStringInSlice(strings.ToLower(e), actual) {
return false
}
}
return true
}
func compareUserFilePatternsFilters(expected *dataprovider.User, actual *dataprovider.User) error {
if len(expected.Filters.FilePatterns) != len(actual.Filters.FilePatterns) {
return errors.New("file patterns mismatch")
}
for _, f := range expected.Filters.FilePatterns {
found := false
for _, f1 := range actual.Filters.FilePatterns {
if path.Clean(f.Path) == path.Clean(f1.Path) {
if !checkFilterMatch(f.AllowedPatterns, f1.AllowedPatterns) ||
!checkFilterMatch(f.DeniedPatterns, f1.DeniedPatterns) {
return errors.New("file patterns contents mismatch")
}
found = true
}
}
if !found {
return errors.New("file patterns contents mismatch")
}
}
return nil return nil
} }
@ -785,19 +819,10 @@ func compareUserFileExtensionsFilters(expected *dataprovider.User, actual *datap
found := false found := false
for _, f1 := range actual.Filters.FileExtensions { for _, f1 := range actual.Filters.FileExtensions {
if path.Clean(f.Path) == path.Clean(f1.Path) { if path.Clean(f.Path) == path.Clean(f1.Path) {
if len(f.AllowedExtensions) != len(f1.AllowedExtensions) || len(f.DeniedExtensions) != len(f1.DeniedExtensions) { if !checkFilterMatch(f.AllowedExtensions, f1.AllowedExtensions) ||
!checkFilterMatch(f.DeniedExtensions, f1.DeniedExtensions) {
return errors.New("file extensions contents mismatch") 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 found = true
} }
} }

View file

@ -378,6 +378,45 @@ func TestAddUserInvalidFilters(t *testing.T) {
_, _, err = httpd.AddUser(u, http.StatusBadRequest) _, _, err = httpd.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err) assert.NoError(t, err)
u.Filters.FileExtensions = nil u.Filters.FileExtensions = nil
u.Filters.FilePatterns = []dataprovider.PatternsFilter{
{
Path: "relative",
AllowedPatterns: []string{},
DeniedPatterns: []string{},
},
}
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err)
u.Filters.FilePatterns = []dataprovider.PatternsFilter{
{
Path: "/",
AllowedPatterns: []string{},
DeniedPatterns: []string{},
},
}
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err)
u.Filters.FilePatterns = []dataprovider.PatternsFilter{
{
Path: "/subdir",
AllowedPatterns: []string{"*.zip"},
},
{
Path: "/subdir",
AllowedPatterns: []string{"*.rar"},
DeniedPatterns: []string{"*.jpg"},
},
}
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err)
u.Filters.FilePatterns = []dataprovider.PatternsFilter{
{
Path: "/subdir",
AllowedPatterns: []string{"a\\"},
},
}
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err)
u.Filters.DeniedProtocols = []string{"invalid"} u.Filters.DeniedProtocols = []string{"invalid"}
_, _, err = httpd.AddUser(u, http.StatusBadRequest) _, _, err = httpd.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err) assert.NoError(t, err)
@ -689,6 +728,11 @@ func TestUpdateUser(t *testing.T) {
AllowedExtensions: []string{".zip", ".rar"}, AllowedExtensions: []string{".zip", ".rar"},
DeniedExtensions: []string{".jpg", ".png"}, DeniedExtensions: []string{".jpg", ".png"},
}) })
user.Filters.FilePatterns = append(user.Filters.FilePatterns, dataprovider.PatternsFilter{
Path: "/subdir",
AllowedPatterns: []string{"*.zip", "*.rar"},
DeniedPatterns: []string{"*.jpg", "*.png"},
})
user.Filters.MaxUploadFileSize = 4096 user.Filters.MaxUploadFileSize = 4096
user.UploadBandwidth = 1024 user.UploadBandwidth = 1024
user.DownloadBandwidth = 512 user.DownloadBandwidth = 512
@ -2411,8 +2455,10 @@ 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 :: 2 :: 1024", mappedDir)) form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v :: 2 :: 1024", mappedDir))
form.Set("allowed_extensions", "/dir1::.jpg,.png") form.Set("allowed_extensions", "/dir2::.jpg,.png\n/dir2::.ico")
form.Set("denied_extensions", "/dir1::.zip") form.Set("denied_extensions", "/dir1::.zip")
form.Set("allowed_patterns", "/dir2::*.jpg,*.png")
form.Set("denied_patterns", "/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)
@ -2546,8 +2592,27 @@ func TestWebUserAddMock(t *testing.T) {
assert.Equal(t, v.QuotaFiles, 2) assert.Equal(t, v.QuotaFiles, 2)
assert.Equal(t, v.QuotaSize, int64(1024)) assert.Equal(t, v.QuotaSize, int64(1024))
} }
extFilters := newUser.Filters.FileExtensions[0] assert.Len(t, newUser.Filters.FileExtensions, 2)
assert.True(t, utils.IsStringInSlice(".zip", extFilters.DeniedExtensions)) for _, filter := range newUser.Filters.FileExtensions {
if filter.Path == "/dir1" {
assert.True(t, utils.IsStringInSlice(".zip", filter.DeniedExtensions))
}
if filter.Path == "/dir2" {
assert.True(t, utils.IsStringInSlice(".jpg", filter.AllowedExtensions))
assert.True(t, utils.IsStringInSlice(".png", filter.AllowedExtensions))
assert.True(t, utils.IsStringInSlice(".ico", filter.AllowedExtensions))
}
}
assert.Len(t, newUser.Filters.FilePatterns, 2)
for _, filter := range newUser.Filters.FilePatterns {
if filter.Path == "/dir1" {
assert.True(t, utils.IsStringInSlice("*.zip", filter.DeniedPatterns))
}
if filter.Path == "/dir2" {
assert.True(t, utils.IsStringInSlice("*.jpg", filter.AllowedPatterns))
assert.True(t, utils.IsStringInSlice("*.png", filter.AllowedPatterns))
}
}
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)

View file

@ -218,6 +218,45 @@ func TestCompareUserFilters(t *testing.T) {
} }
err = checkUser(expected, actual) err = checkUser(expected, actual)
assert.Error(t, err) assert.Error(t, err)
actual.Filters.FileExtensions = nil
actual.Filters.FilePatterns = nil
expected.Filters.FileExtensions = nil
expected.Filters.FilePatterns = nil
expected.Filters.FilePatterns = append(expected.Filters.FilePatterns, dataprovider.PatternsFilter{
Path: "/",
AllowedPatterns: []string{"*.jpg", "*.png"},
DeniedPatterns: []string{"*.zip", "*.rar"},
})
err = checkUser(expected, actual)
assert.Error(t, err)
actual.Filters.FilePatterns = append(actual.Filters.FilePatterns, dataprovider.PatternsFilter{
Path: "/sub",
AllowedPatterns: []string{"*.jpg", "*.png"},
DeniedPatterns: []string{"*.zip", "*.rar"},
})
err = checkUser(expected, actual)
assert.Error(t, err)
actual.Filters.FilePatterns[0] = dataprovider.PatternsFilter{
Path: "/",
AllowedPatterns: []string{"*.jpg"},
DeniedPatterns: []string{"*.zip", "*.rar"},
}
err = checkUser(expected, actual)
assert.Error(t, err)
actual.Filters.FilePatterns[0] = dataprovider.PatternsFilter{
Path: "/",
AllowedPatterns: []string{"*.tiff", "*.png"},
DeniedPatterns: []string{"*.zip", "*.rar"},
}
err = checkUser(expected, actual)
assert.Error(t, err)
actual.Filters.FilePatterns[0] = dataprovider.PatternsFilter{
Path: "/",
AllowedPatterns: []string{"*.jpg", "*.png"},
DeniedPatterns: []string{"*.tar.gz", "*.rar"},
}
err = checkUser(expected, actual)
assert.Error(t, err)
} }
func TestCompareUserFields(t *testing.T) { func TestCompareUserFields(t *testing.T) {

View file

@ -2,7 +2,7 @@ openapi: 3.0.3
info: info:
title: SFTPGo title: SFTPGo
description: 'SFTPGo REST API' description: 'SFTPGo REST API'
version: 2.0.2 version: 2.0.3
servers: servers:
- url: /api/v1 - url: /api/v1
@ -869,12 +869,32 @@ components:
- 'SSH' - 'SSH'
- 'FTP' - 'FTP'
- 'DAV' - 'DAV'
PatternsFilter:
type: object
properties:
path:
type: string
description: exposed virtual 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_patterns:
type: array
items:
type: string
nullable: true
description: list of, case insensitive, allowed shell like file patterns.
example: [ "*.jpg", "a*b?.png" ]
denied_patterns:
type: array
items:
type: string
nullable: true
description: list of, case insensitive, denied shell like file patterns. Denied patterns are evaluated before the allowed ones
example: [ "*.zip" ]
ExtensionsFilter: ExtensionsFilter:
type: object type: object
properties: properties:
path: path:
type: string type: string
description: exposed SFTPGo 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 description: exposed virtual 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: allowed_extensions:
type: array type: array
items: items:
@ -918,12 +938,18 @@ components:
$ref: '#/components/schemas/SupportedProtocols' $ref: '#/components/schemas/SupportedProtocols'
nullable: true nullable: true
description: if null or empty any available protocol is allowed description: if null or empty any available protocol is allowed
file_patterns:
type: array
items:
$ref: '#/components/schemas/PatternsFilter'
nullable: true
description: filters based on shell like file patterns. 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 in the list of files. Please note that these restrictions can be easily bypassed
file_extensions: file_extensions:
type: array type: array
items: items:
$ref: '#/components/schemas/ExtensionsFilter' $ref: '#/components/schemas/ExtensionsFilter'
nullable: true 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: filters based on shell like patterns. Deprecated, use file_patterns. 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 in the list of files. Please note that these restrictions can be easily bypassed
max_upload_file_size: max_upload_file_size:
type: integer type: integer
format: int64 format: int64

View file

@ -6,7 +6,6 @@ import (
"html/template" "html/template"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@ -308,8 +307,8 @@ func getSliceFromDelimitedValues(values, delimiter string) []string {
return result return result
} }
func getFileExtensionsFromPostField(value string, extesionsType int) []dataprovider.ExtensionsFilter { func getListFromPostFields(value string) map[string][]string {
var result []dataprovider.ExtensionsFilter result := make(map[string][]string)
for _, cleaned := range getSliceFromDelimitedValues(value, "\n") { for _, cleaned := range getSliceFromDelimitedValues(value, "\n") {
if strings.Contains(cleaned, "::") { if strings.Contains(cleaned, "::") {
dirExts := strings.Split(cleaned, "::") dirExts := strings.Split(cleaned, "::")
@ -319,22 +318,16 @@ func getFileExtensionsFromPostField(value string, extesionsType int) []dataprovi
exts := []string{} exts := []string{}
for _, e := range strings.Split(dirExts[1], ",") { for _, e := range strings.Split(dirExts[1], ",") {
cleanedExt := strings.TrimSpace(e) cleanedExt := strings.TrimSpace(e)
if len(cleanedExt) > 0 { if cleanedExt != "" {
exts = append(exts, cleanedExt) exts = append(exts, cleanedExt)
} }
} }
if len(dir) > 0 { if dir != "" {
filter := dataprovider.ExtensionsFilter{ if _, ok := result[dir]; ok {
Path: dir, result[dir] = append(result[dir], exts...)
}
if extesionsType == 1 {
filter.AllowedExtensions = exts
filter.DeniedExtensions = []string{}
} else { } else {
filter.DeniedExtensions = exts result[dir] = exts
filter.AllowedExtensions = []string{}
} }
result = append(result, filter)
} }
} }
} }
@ -342,6 +335,42 @@ func getFileExtensionsFromPostField(value string, extesionsType int) []dataprovi
return result return result
} }
func getFilePatternsFromPostField(value string, extesionsType int) []dataprovider.PatternsFilter {
var result []dataprovider.PatternsFilter
for dir, values := range getListFromPostFields(value) {
filter := dataprovider.PatternsFilter{
Path: dir,
}
if extesionsType == 1 {
filter.AllowedPatterns = values
filter.DeniedPatterns = []string{}
} else {
filter.DeniedPatterns = values
filter.AllowedPatterns = []string{}
}
result = append(result, filter)
}
return result
}
func getFileExtensionsFromPostField(value string, extesionsType int) []dataprovider.ExtensionsFilter {
var result []dataprovider.ExtensionsFilter
for dir, values := range getListFromPostFields(value) {
filter := dataprovider.ExtensionsFilter{
Path: dir,
}
if extesionsType == 1 {
filter.AllowedExtensions = values
filter.DeniedExtensions = []string{}
} else {
filter.DeniedExtensions = values
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"), ",")
@ -351,33 +380,16 @@ func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters {
allowedExtensions := getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), 1) allowedExtensions := getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), 1)
deniedExtensions := getFileExtensionsFromPostField(r.Form.Get("denied_extensions"), 2) deniedExtensions := getFileExtensionsFromPostField(r.Form.Get("denied_extensions"), 2)
extensions := []dataprovider.ExtensionsFilter{} extensions := []dataprovider.ExtensionsFilter{}
if len(allowedExtensions) > 0 && len(deniedExtensions) > 0 { extensions = append(extensions, allowedExtensions...)
for _, allowed := range allowedExtensions { extensions = append(extensions, deniedExtensions...)
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 filters.FileExtensions = extensions
allowedPatterns := getFilePatternsFromPostField(r.Form.Get("allowed_patterns"), 1)
deniedPatterns := getFilePatternsFromPostField(r.Form.Get("denied_patterns"), 2)
patterns := []dataprovider.PatternsFilter{}
patterns = append(patterns, allowedPatterns...)
patterns = append(patterns, deniedPatterns...)
filters.FilePatterns = patterns
return filters return filters
} }

View file

@ -103,9 +103,9 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort int, enabledS
s.advertiseServices(advertiseService, advertiseCredentials) s.advertiseServices(advertiseService, advertiseCredentials)
logger.InfoToConsole("Portable mode ready, SFTP port: %v, user: %#v, password: %#v, public keys: %v, directory: %#v, "+ logger.InfoToConsole("Portable mode ready, SFTP port: %v, user: %#v, password: %#v, public keys: %v, directory: %#v, "+
"permissions: %+v, enabled ssh commands: %v file extensions filters: %+v %v", sftpdConf.BindPort, s.PortableUser.Username, "permissions: %+v, enabled ssh commands: %v file patterns filters: %+v %v", sftpdConf.BindPort, s.PortableUser.Username,
printablePassword, s.PortableUser.PublicKeys, s.getPortableDirToServe(), s.PortableUser.Permissions, printablePassword, s.PortableUser.PublicKeys, s.getPortableDirToServe(), s.PortableUser.Permissions,
sftpdConf.EnabledSSHCommands, s.PortableUser.Filters.FileExtensions, s.getServiceOptionalInfoString()) sftpdConf.EnabledSSHCommands, s.PortableUser.Filters.FilePatterns, s.getServiceOptionalInfoString())
return nil return nil
} }

View file

@ -394,12 +394,12 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co
// Create the server instance for the channel using the handler we created above. // Create the server instance for the channel using the handler we created above.
server := sftp.NewRequestServer(channel, handler, sftp.WithRSAllocator()) server := sftp.NewRequestServer(channel, handler, sftp.WithRSAllocator())
defer server.Close()
if err := server.Serve(); err == io.EOF { if err := server.Serve(); err == io.EOF {
connection.Log(logger.LevelDebug, "connection closed, sending exit status") connection.Log(logger.LevelDebug, "connection closed, sending exit status")
exitStatus := sshSubsystemExitStatus{Status: uint32(0)} exitStatus := sshSubsystemExitStatus{Status: uint32(0)}
_, err = channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatus)) _, err = channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatus))
connection.Log(logger.LevelDebug, "sent exit status %+v error: %v", exitStatus, err) connection.Log(logger.LevelDebug, "sent exit status %+v error: %v", exitStatus, err)
server.Close()
} else if err != nil { } else if err != nil {
connection.Log(logger.LevelWarn, "connection closed with error: %v", err) connection.Log(logger.LevelWarn, "connection closed with error: %v", err)
} }

View file

@ -2554,7 +2554,8 @@ func TestBandwidthAndConnections(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestExtensionsFilters(t *testing.T) { //nolint:dupl
func TestPatternsFilters(t *testing.T) {
usePubKey := true usePubKey := true
u := getTestUser(usePubKey) u := getTestUser(usePubKey)
user, _, err := httpd.AddUser(u, http.StatusOK) user, _, err := httpd.AddUser(u, http.StatusOK)
@ -2569,12 +2570,14 @@ func TestExtensionsFilters(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
assert.NoError(t, err) assert.NoError(t, err)
err = sftpUploadFile(testFilePath, testFileName+".zip", testFileSize, client)
assert.NoError(t, err)
} }
user.Filters.FileExtensions = []dataprovider.ExtensionsFilter{ user.Filters.FilePatterns = []dataprovider.PatternsFilter{
{ {
Path: "/", Path: "/",
AllowedExtensions: []string{".zip"}, AllowedPatterns: []string{"*.zIp"},
DeniedExtensions: []string{}, DeniedPatterns: []string{},
}, },
} }
_, _, err = httpd.UpdateUser(user, http.StatusOK, "") _, _, err = httpd.UpdateUser(user, http.StatusOK, "")
@ -2590,6 +2593,75 @@ func TestExtensionsFilters(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
err = client.Remove(testFileName) err = client.Remove(testFileName)
assert.Error(t, err) assert.Error(t, err)
err = sftpDownloadFile(testFileName+".zip", localDownloadPath, testFileSize, client)
assert.NoError(t, err)
err = client.Mkdir("dir.zip")
assert.NoError(t, err)
err = client.Rename("dir.zip", "dir1.zip")
assert.NoError(t, err)
}
_, err = httpd.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.Remove(testFilePath)
assert.NoError(t, err)
err = os.Remove(localDownloadPath)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
//nolint:dupl
func TestExtensionsFilters(t *testing.T) {
usePubKey := true
u := getTestUser(usePubKey)
user, _, err := httpd.AddUser(u, http.StatusOK)
assert.NoError(t, err)
testFileSize := int64(131072)
testFilePath := filepath.Join(homeBasePath, testFileName)
localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
client, err := getSftpClient(user, usePubKey)
if assert.NoError(t, err) {
defer client.Close()
err = createTestFile(testFilePath, testFileSize)
assert.NoError(t, err)
err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
assert.NoError(t, err)
err = sftpUploadFile(testFilePath, testFileName+".zip", testFileSize, client)
assert.NoError(t, err)
err = sftpUploadFile(testFilePath, testFileName+".jpg", testFileSize, client)
assert.NoError(t, err)
}
user.Filters.FileExtensions = []dataprovider.ExtensionsFilter{
{
Path: "/",
AllowedExtensions: []string{".zIp", ".jPg"},
DeniedExtensions: []string{},
},
}
user.Filters.FilePatterns = []dataprovider.PatternsFilter{
{
Path: "/",
AllowedPatterns: []string{"*.jPg", "*.zIp"},
DeniedPatterns: []string{},
},
}
_, _, err = httpd.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
client, err = getSftpClient(user, usePubKey)
if assert.NoError(t, err) {
defer client.Close()
err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
assert.Error(t, err)
err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
assert.Error(t, err)
err = client.Rename(testFileName, testFileName+"1")
assert.Error(t, err)
err = client.Remove(testFileName)
assert.Error(t, err)
err = sftpDownloadFile(testFileName+".zip", localDownloadPath, testFileSize, client)
assert.NoError(t, err)
err = sftpDownloadFile(testFileName+".jpg", localDownloadPath, testFileSize, client)
assert.NoError(t, err)
err = client.Mkdir("dir.zip") err = client.Mkdir("dir.zip")
assert.NoError(t, err) assert.NoError(t, err)
err = client.Rename("dir.zip", "dir1.zip") err = client.Rename("dir.zip", "dir1.zip")
@ -5731,6 +5803,44 @@ func TestUserPerms(t *testing.T) {
assert.True(t, user.HasPerm(dataprovider.PermDownload, "/p/1/test/file.dat")) assert.True(t, user.HasPerm(dataprovider.PermDownload, "/p/1/test/file.dat"))
} }
//nolint:dupl
func TestFilterFilePatterns(t *testing.T) {
user := getTestUser(true)
pattern := dataprovider.PatternsFilter{
Path: "/test",
AllowedPatterns: []string{"*.jpg", "*.png"},
DeniedPatterns: []string{"*.pdf"},
}
filters := dataprovider.UserFilters{
FilePatterns: []dataprovider.PatternsFilter{pattern},
}
user.Filters = filters
assert.True(t, user.IsFileAllowed("/test/test.jPg"))
assert.False(t, user.IsFileAllowed("/test/test.pdf"))
assert.True(t, user.IsFileAllowed("/test.pDf"))
filters.FilePatterns = append(filters.FilePatterns, dataprovider.PatternsFilter{
Path: "/",
AllowedPatterns: []string{"*.zip", "*.rar", "*.pdf"},
DeniedPatterns: []string{"*.gz"},
})
user.Filters = filters
assert.False(t, user.IsFileAllowed("/test1/test.gz"))
assert.True(t, user.IsFileAllowed("/test1/test.zip"))
assert.False(t, user.IsFileAllowed("/test/sub/test.pdf"))
assert.False(t, user.IsFileAllowed("/test1/test.png"))
filters.FilePatterns = append(filters.FilePatterns, dataprovider.PatternsFilter{
Path: "/test/sub",
DeniedPatterns: []string{"*.tar"},
})
user.Filters = filters
assert.False(t, user.IsFileAllowed("/test/sub/sub/test.tar"))
assert.True(t, user.IsFileAllowed("/test/sub/test.gz"))
assert.False(t, user.IsFileAllowed("/test/test.zip"))
}
//nolint:dupl
func TestFilterFileExtensions(t *testing.T) { func TestFilterFileExtensions(t *testing.T) {
user := getTestUser(true) user := getTestUser(true)
extension := dataprovider.ExtensionsFilter{ extension := dataprovider.ExtensionsFilter{

View file

@ -119,7 +119,7 @@
{{- end}} {{- end}}
{{- end}}</textarea> {{- end}}</textarea>
<small id="subDirsHelpBlock" class="form-text text-muted"> <small id="subDirsHelpBlock" class="form-text text-muted">
One virtual directory path per line as dir::perms, for example /somedir::list,download One exposed virtual directory path per line as dir::perms, for example /somedir::list,download
</small> </small>
</div> </div>
</div> </div>
@ -241,6 +241,36 @@
</div> </div>
</div> </div>
<div class="form-group row">
<label for="idFilePatternsDenied" class="col-sm-2 col-form-label">Denied file patterns</label>
<div class="col-sm-10">
<textarea class="form-control" id="idFilePatternsDenied" name="denied_patterns" rows="3"
aria-describedby="deniedPatternsHelpBlock">{{range $index, $filter := .User.Filters.FilePatterns -}}
{{if $filter.DeniedPatterns -}}
{{$filter.Path}}::{{range $idx, $p := $filter.DeniedPatterns}}{{if $idx}},{{end}}{{$p}}{{end}}&#10;
{{- end}}
{{- end}}</textarea>
<small id="deniedPatternsHelpBlock" class="form-text text-muted">
One exposed virtual directory per line as dir::pattern1,pattern2, for example /subdir::*.zip,*.rar
</small>
</div>
</div>
<div class="form-group row">
<label for="idFilePatternsAllowed" class="col-sm-2 col-form-label">Allowed file patterns</label>
<div class="col-sm-10">
<textarea class="form-control" id="idFilePatternsAllowed" name="allowed_patterns" rows="3"
aria-describedby="allowedPatternsHelpBlock">{{range $index, $filter := .User.Filters.FilePatterns -}}
{{if $filter.AllowedPatterns -}}
{{$filter.Path}}::{{range $idx, $p := $filter.AllowedPatterns}}{{if $idx}},{{end}}{{$p}}{{end}}&#10;
{{- end}}
{{- end}}</textarea>
<small id="allowedPatternsHelpBlock" class="form-text text-muted">
One exposed virtual directory per line as dir::pattern1,pattern2, for example /somedir::*.jpg,*.png
</small>
</div>
</div>
<div class="form-group row"> <div class="form-group row">
<label for="idFilesExtensionsDenied" class="col-sm-2 col-form-label">Denied file extensions</label> <label for="idFilesExtensionsDenied" class="col-sm-2 col-form-label">Denied file extensions</label>
<div class="col-sm-10"> <div class="col-sm-10">
@ -251,7 +281,7 @@
{{- end}} {{- end}}
{{- end}}</textarea> {{- end}}</textarea>
<small id="deniedExtensionsHelpBlock" class="form-text text-muted"> <small id="deniedExtensionsHelpBlock" class="form-text text-muted">
One directory per line as dir::extensions1,extensions2, for example /subdir::.zip,.rar One exposed virtual directory per line as dir::extension1,extension2, for example /subdir::.zip,.rar. Deprecated, use file patterns
</small> </small>
</div> </div>
</div> </div>
@ -266,7 +296,7 @@
{{- end}} {{- end}}
{{- end}}</textarea> {{- end}}</textarea>
<small id="allowedExtensionsHelpBlock" class="form-text text-muted"> <small id="allowedExtensionsHelpBlock" class="form-text text-muted">
One directory per line as dir::extensions1,extensions2, for example /somedir::.jpg,.png One exposed virtual directory per line as dir::extension1,extension2, for example /somedir::.jpg,.png. Deprecated, use file patterns
</small> </small>
</div> </div>
</div> </div>

View file

@ -536,11 +536,19 @@ func TestDownloadErrors(t *testing.T) {
DeniedExtensions: []string{".zipp"}, DeniedExtensions: []string{".zipp"},
}, },
} }
u.Filters.FilePatterns = []dataprovider.PatternsFilter{
{
Path: "/sub2",
AllowedPatterns: []string{},
DeniedPatterns: []string{"*.jpg"},
},
}
user, _, err := httpd.AddUser(u, http.StatusOK) user, _, err := httpd.AddUser(u, http.StatusOK)
assert.NoError(t, err) assert.NoError(t, err)
client := getWebDavClient(user) client := getWebDavClient(user)
testFilePath1 := filepath.Join(user.HomeDir, subDir1, "file.zipp") testFilePath1 := filepath.Join(user.HomeDir, subDir1, "file.zipp")
testFilePath2 := filepath.Join(user.HomeDir, subDir2, "file.zipp") testFilePath2 := filepath.Join(user.HomeDir, subDir2, "file.zipp")
testFilePath3 := filepath.Join(user.HomeDir, subDir2, "file.jpg")
err = os.MkdirAll(filepath.Dir(testFilePath1), os.ModePerm) err = os.MkdirAll(filepath.Dir(testFilePath1), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
err = os.MkdirAll(filepath.Dir(testFilePath2), os.ModePerm) err = os.MkdirAll(filepath.Dir(testFilePath2), os.ModePerm)
@ -549,11 +557,15 @@ func TestDownloadErrors(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
err = ioutil.WriteFile(testFilePath2, []byte("file2"), os.ModePerm) err = ioutil.WriteFile(testFilePath2, []byte("file2"), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
err = ioutil.WriteFile(testFilePath3, []byte("file3"), os.ModePerm)
assert.NoError(t, err)
localDownloadPath := filepath.Join(homeBasePath, testDLFileName) localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
err = downloadFile(path.Join("/", subDir1, "file.zipp"), localDownloadPath, 5, client) err = downloadFile(path.Join("/", subDir1, "file.zipp"), localDownloadPath, 5, client)
assert.Error(t, err) assert.Error(t, err)
err = downloadFile(path.Join("/", subDir2, "file.zipp"), localDownloadPath, 5, client) err = downloadFile(path.Join("/", subDir2, "file.zipp"), localDownloadPath, 5, client)
assert.Error(t, err) assert.Error(t, err)
err = downloadFile(path.Join("/", subDir2, "file.jpg"), localDownloadPath, 5, client)
assert.Error(t, err)
err = downloadFile(path.Join("missing.zip"), localDownloadPath, 5, client) err = downloadFile(path.Join("missing.zip"), localDownloadPath, 5, client)
assert.Error(t, err) assert.Error(t, err)