filters: we can now set allowed and denied files extensions

This commit is contained in:
Nicola Murino 2020-03-01 22:10:29 +01:00
parent 7163fde724
commit b885d453a2
17 changed files with 826 additions and 59 deletions

View file

@ -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`

View file

@ -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
}

View file

@ -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{

View file

@ -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 ||

View file

@ -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
}

View file

@ -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)

View file

@ -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) {

View file

@ -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

View file

@ -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
}

View file

@ -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:

View file

@ -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':

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

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