diff --git a/README.md b/README.md index d31ae0c2..b05e14f0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Full featured and highly configurable SFTP server - Quota support: accounts can have individual quota expressed as max total size and/or max number of files. - Bandwidth throttling is supported, with distinct settings for upload and download. - Per user maximum concurrent sessions. -- Per user permissions: list directories content, upload, overwrite, download, delete, rename, create directories, create symlinks, changing owner/group and mode, changing access and modification times can be enabled or disabled. +- Per user and per directory permissions: list directories content, upload, overwrite, download, delete, rename, create directories, create symlinks, changing owner/group and mode, changing access and modification times can be enabled or disabled. - Per user files/folders ownership: 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). - 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. @@ -367,7 +367,7 @@ For each account the following properties can be configured: - `max_sessions` maximum concurrent sessions. 0 means unlimited. - `quota_size` maximum size allowed as bytes. 0 means unlimited. - `quota_files` maximum number of files allowed. 0 means unlimited. -- `permissions` the following permissions are supported: +- `permissions` the following per directory permissions are supported: - `*` all permissions are granted - `list` list items is allowed - `download` download files is allowed diff --git a/cmd/portable.go b/cmd/portable.go index 6e2770ec..3beacfc6 100644 --- a/cmd/portable.go +++ b/cmd/portable.go @@ -33,6 +33,8 @@ Please take a look at the usage below to customize the serving parameters`, if !filepath.IsAbs(portableDir) { portableDir, _ = filepath.Abs(portableDir) } + permissions := make(map[string][]string) + permissions["/"] = portablePermissions service := service.Service{ ConfigDir: defaultConfigDir, ConfigFile: defaultConfigName, @@ -48,7 +50,7 @@ Please take a look at the usage below to customize the serving parameters`, Username: portableUsername, Password: portablePassword, PublicKeys: portablePublicKeys, - Permissions: portablePermissions, + Permissions: permissions, HomeDir: portableDir, Status: 1, }, diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 0db7a152..8149cdb7 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -14,7 +14,7 @@ import ( ) const ( - databaseVersion = 2 + databaseVersion = 3 ) var ( @@ -33,6 +33,28 @@ type boltDatabaseVersion struct { Version int } +type compatUserV2 struct { + ID int64 `json:"id"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + PublicKeys []string `json:"public_keys,omitempty"` + HomeDir string `json:"home_dir"` + UID int `json:"uid"` + GID int `json:"gid"` + MaxSessions int `json:"max_sessions"` + QuotaSize int64 `json:"quota_size"` + QuotaFiles int `json:"quota_files"` + Permissions []string `json:"permissions"` + UsedQuotaSize int64 `json:"used_quota_size"` + UsedQuotaFiles int `json:"used_quota_files"` + LastQuotaUpdate int64 `json:"last_quota_update"` + UploadBandwidth int64 `json:"upload_bandwidth"` + DownloadBandwidth int64 `json:"download_bandwidth"` + ExpirationDate int64 `json:"expiration_date"` + LastLogin int64 `json:"last_login"` + Status int `json:"status"` +} + func initializeBoltProvider(basePath string) error { var err error logSender = BoltDataProviderName @@ -376,27 +398,91 @@ func checkBoltDatabaseVersion(dbHandle *bolt.DB) error { return nil } if dbVersion.Version == 1 { - providerLog(logger.LevelInfo, "update bolt database version: 1 -> 2") - usernames, err := getBoltAvailableUsernames(dbHandle) + err = updateDatabaseFrom1To2(dbHandle) if err != nil { return err } - for _, u := range usernames { - user, err := provider.userExists(u) - if err != nil { - return err - } - user.Status = 1 - err = provider.updateUser(user) - if err != nil { - return err - } - providerLog(logger.LevelInfo, "user %#v updated, \"status\" setted to 1", user.Username) - } - return updateBoltDatabaseVersion(dbHandle, 2) + return updateDatabaseFrom2To3(dbHandle) + } else if dbVersion.Version == 2 { + return updateDatabaseFrom2To3(dbHandle) } - return err + return nil +} + +func updateDatabaseFrom1To2(dbHandle *bolt.DB) error { + providerLog(logger.LevelInfo, "updating bolt database version: 1 -> 2") + usernames, err := getBoltAvailableUsernames(dbHandle) + if err != nil { + return err + } + for _, u := range usernames { + user, err := provider.userExists(u) + if err != nil { + return err + } + user.Status = 1 + err = provider.updateUser(user) + if err != nil { + return err + } + providerLog(logger.LevelInfo, "user %#v updated, \"status\" setted to 1", user.Username) + } + return updateBoltDatabaseVersion(dbHandle, 2) +} + +func updateDatabaseFrom2To3(dbHandle *bolt.DB) error { + providerLog(logger.LevelInfo, "updating bolt database version: 2 -> 3") + users := []User{} + err := dbHandle.View(func(tx *bolt.Tx) error { + bucket, _, err := getBuckets(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var compatUser compatUserV2 + err = json.Unmarshal(v, &compatUser) + if err == nil { + user := User{} + user.ID = compatUser.ID + user.Username = compatUser.Username + user.Password = compatUser.Password + user.PublicKeys = compatUser.PublicKeys + user.HomeDir = compatUser.HomeDir + user.UID = compatUser.UID + user.GID = compatUser.GID + user.MaxSessions = compatUser.MaxSessions + user.QuotaSize = compatUser.QuotaSize + user.QuotaFiles = compatUser.QuotaFiles + user.Permissions = make(map[string][]string) + user.Permissions["/"] = compatUser.Permissions + user.UsedQuotaSize = compatUser.UsedQuotaSize + user.UsedQuotaFiles = compatUser.UsedQuotaFiles + user.LastQuotaUpdate = compatUser.LastQuotaUpdate + user.UploadBandwidth = compatUser.UploadBandwidth + user.DownloadBandwidth = compatUser.DownloadBandwidth + user.ExpirationDate = compatUser.ExpirationDate + user.LastLogin = compatUser.LastLogin + user.Status = compatUser.Status + users = append(users, user) + } + } + return err + }) + if err != nil { + return err + } + + for _, user := range users { + err = provider.updateUser(user) + if err != nil { + return err + } + providerLog(logger.LevelInfo, "user %#v updated, \"permissions\" setted to %+v", user.Username, user.Permissions) + } + + return updateBoltDatabaseVersion(dbHandle, 3) } func getBoltAvailableUsernames(dbHandle *bolt.DB) ([]string, error) { diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 5cf72c57..85d2fd87 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -18,6 +18,7 @@ import ( "net/url" "os" "os/exec" + "path" "path/filepath" "strconv" "strings" @@ -343,14 +344,33 @@ func buildUserHomeDir(user *User) { } func validatePermissions(user *User) error { - for _, p := range user.Permissions { - if !utils.IsStringInSlice(p, ValidPerms) { - return &ValidationError{err: fmt.Sprintf("Invalid permission: %v", p)} + permissions := make(map[string][]string) + if _, ok := user.Permissions["/"]; !ok { + return &ValidationError{err: fmt.Sprintf("Permissions for the root dir \"/\" must be set")} + } + for dir, perms := range user.Permissions { + if len(perms) == 0 { + return &ValidationError{err: fmt.Sprintf("No permissions granted for the directory: %#v", dir)} + } + for _, p := range perms { + if !utils.IsStringInSlice(p, ValidPerms) { + return &ValidationError{err: fmt.Sprintf("Invalid permission: %#v", p)} + } + } + cleanedDir := filepath.ToSlash(path.Clean(dir)) + if cleanedDir != "/" { + cleanedDir = strings.TrimSuffix(cleanedDir, "/") + } + if !path.IsAbs(cleanedDir) { + return &ValidationError{err: fmt.Sprintf("Cannot set permissions for non absolute path: %#v", dir)} + } + if utils.IsStringInSlice(PermAny, perms) { + permissions[cleanedDir] = []string{PermAny} + } else { + permissions[cleanedDir] = perms } } - if utils.IsStringInSlice(PermAny, user.Permissions) { - user.Permissions = []string{PermAny} - } + user.Permissions = permissions return nil } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index c70e16f4..f1cbaf5e 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -265,10 +265,18 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { } } if permissions.Valid { - var list []string - err = json.Unmarshal([]byte(permissions.String), &list) + perms := make(map[string][]string) + err = json.Unmarshal([]byte(permissions.String), &perms) if err == nil { - user.Permissions = list + user.Permissions = perms + } else { + // compatibility layer: until version 0.9.4 permissions were a string list + var list []string + err = json.Unmarshal([]byte(permissions.String), &list) + if err == nil { + perms["/"] = list + user.Permissions = perms + } } } return user, err diff --git a/dataprovider/user.go b/dataprovider/user.go index 3fef0841..81dc2fb9 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -3,8 +3,10 @@ package dataprovider import ( "encoding/json" "fmt" + "path" "path/filepath" "strconv" + "strings" "github.com/drakkan/sftpgo/utils" ) @@ -68,7 +70,7 @@ type User struct { // Maximum number of files allowed. 0 means unlimited QuotaFiles int `json:"quota_files"` // List of the granted permissions - Permissions []string `json:"permissions"` + Permissions map[string][]string `json:"permissions"` // Used quota as bytes UsedQuotaSize int64 `json:"used_quota_size"` // Used quota as number of files @@ -83,21 +85,59 @@ type User struct { LastLogin int64 `json:"last_login"` } +// GetPermissionsForPath returns the permissions for the given path +func (u *User) GetPermissionsForPath(p string) []string { + permissions := []string{} + if perms, ok := u.Permissions["/"]; ok { + // if only root permissions are defined returns them unconditionally + if len(u.Permissions) == 1 { + return perms + } + // fallback permissions + permissions = perms + } + relPath := u.GetRelativePath(p) + if len(relPath) == 0 { + relPath = "/" + } + dirsForPath := []string{relPath} + for { + if relPath == "/" { + break + } + relPath = path.Dir(relPath) + dirsForPath = append(dirsForPath, relPath) + } + // dirsForPath contains all the dirs for a given path in reverse order + // for example if the path is: /1/2/3/4 it contains: + // [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ] + // so the first match is the one we are interested to + for _, val := range dirsForPath { + if perms, ok := u.Permissions[val]; ok { + permissions = perms + break + } + } + return permissions +} + // HasPerm returns true if the user has the given permission or any permission -func (u *User) HasPerm(permission string) bool { - if utils.IsStringInSlice(PermAny, u.Permissions) { +func (u *User) HasPerm(permission, path string) bool { + perms := u.GetPermissionsForPath(path) + if utils.IsStringInSlice(PermAny, perms) { return true } - return utils.IsStringInSlice(permission, u.Permissions) + return utils.IsStringInSlice(permission, perms) } // HasPerms return true if the user has all the given permissions -func (u *User) HasPerms(permissions []string) bool { - if utils.IsStringInSlice(PermAny, u.Permissions) { +func (u *User) HasPerms(permissions []string, path string) bool { + perms := u.GetPermissionsForPath(path) + if utils.IsStringInSlice(PermAny, perms) { return true } for _, permission := range permissions { - if !utils.IsStringInSlice(permission, u.Permissions) { + if !utils.IsStringInSlice(permission, perms) { return false } } @@ -143,10 +183,13 @@ func (u *User) HasQuotaRestrictions() bool { // GetRelativePath returns the path for a file relative to the user's home dir. // This is the path as seen by SFTP users func (u *User) GetRelativePath(path string) string { - rel, err := filepath.Rel(u.GetHomeDir(), path) + rel, err := filepath.Rel(u.GetHomeDir(), filepath.Clean(path)) if err != nil { return "" } + if rel == "." || strings.HasPrefix(rel, "..") { + rel = "" + } return "/" + filepath.ToSlash(rel) } @@ -168,12 +211,28 @@ func (u *User) GetQuotaSummary() string { // GetPermissionsAsString returns the user's permissions as comma separated string func (u *User) GetPermissionsAsString() string { - var result string - for _, p := range u.Permissions { - if len(result) > 0 { - result += ", " + result := "" + for dir, perms := range u.Permissions { + var dirPerms string + for _, p := range perms { + if len(dirPerms) > 0 { + dirPerms += ", " + } + dirPerms += p + } + dp := fmt.Sprintf("%#v: %#v", dir, dirPerms) + if dir == "/" { + if len(result) > 0 { + result = dp + ", " + result + } else { + result = dp + } + } else { + if len(result) > 0 { + result += ", " + } + result += dp } - result += p } return result } @@ -230,8 +289,12 @@ func (u *User) GetExpirationDateAsString() string { func (u *User) getACopy() User { pubKeys := make([]string, len(u.PublicKeys)) copy(pubKeys, u.PublicKeys) - permissions := make([]string, len(u.Permissions)) - copy(permissions, u.Permissions) + permissions := make(map[string][]string) + for k, v := range u.Permissions { + perms := make([]string, len(v)) + copy(perms, v) + permissions[k] = perms + } return User{ ID: u.ID, Username: u.Username, diff --git a/httpd/api_user.go b/httpd/api_user.go index eae03b73..30b33328 100644 --- a/httpd/api_user.go +++ b/httpd/api_user.go @@ -102,6 +102,8 @@ func updateUser(w http.ResponseWriter, r *http.Request) { return } user, err := dataprovider.GetUserByID(dataProvider, userID) + oldPermissions := user.Permissions + user.Permissions = make(map[string][]string) if _, ok := err.(*dataprovider.RecordNotFoundError); ok { sendAPIResponse(w, r, err, "", http.StatusNotFound) return @@ -114,6 +116,10 @@ func updateUser(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "", http.StatusBadRequest) return } + // we use new Permissions if passed otherwise the old ones + if len(user.Permissions) == 0 { + user.Permissions = oldPermissions + } if user.ID != userID { sendAPIResponse(w, r, err, "user ID in request body does not match user ID in path parameter", http.StatusBadRequest) return diff --git a/httpd/api_utils.go b/httpd/api_utils.go index e504bc15..0121dcfe 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -329,9 +329,15 @@ func checkUser(expected dataprovider.User, actual dataprovider.User) error { return errors.New("user ID mismatch") } } - for _, v := range expected.Permissions { - if !utils.IsStringInSlice(v, actual.Permissions) { - return errors.New("Permissions contents mismatch") + for dir, perms := range expected.Permissions { + if actualPerms, ok := actual.Permissions[dir]; ok { + for _, v := range actualPerms { + if !utils.IsStringInSlice(v, perms) { + return errors.New("Permissions contents mismatch") + } + } + } else { + return errors.New("Permissions directories mismatch") } } return compareEqualsUserFields(expected, actual) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 295e321e..e2e1f040 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -194,20 +194,31 @@ func TestAddUserInvalidHomeDir(t *testing.T) { func TestAddUserNoPerms(t *testing.T) { u := getTestUser() - u.Permissions = []string{} + u.Permissions = make(map[string][]string) _, _, err := httpd.AddUser(u, http.StatusBadRequest) if err != nil { t.Errorf("unexpected error adding user with no perms: %v", err) } + u.Permissions["/"] = []string{} + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with no perms: %v", err) + } } func TestAddUserInvalidPerms(t *testing.T) { u := getTestUser() - u.Permissions = []string{"invalidPerm"} + u.Permissions["/"] = []string{"invalidPerm"} _, _, err := httpd.AddUser(u, http.StatusBadRequest) if err != nil { t.Errorf("unexpected error adding user with no perms: %v", err) } + // permissions for root dir are mandatory + u.Permissions["/somedir"] = []string{dataprovider.PermAny} + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with no perms: %v", err) + } } func TestUserPublicKey(t *testing.T) { @@ -251,7 +262,8 @@ func TestUpdateUser(t *testing.T) { user.MaxSessions = 10 user.QuotaSize = 4096 user.QuotaFiles = 2 - user.Permissions = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload} + user.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload} + user.Permissions["/subdir"] = []string{dataprovider.PermListItems, dataprovider.PermUpload} user.UploadBandwidth = 1024 user.DownloadBandwidth = 512 user, _, err = httpd.UpdateUser(user, http.StatusOK) @@ -556,7 +568,7 @@ func TestBasicUserHandlingMock(t *testing.T) { checkResponseCode(t, http.StatusInternalServerError, rr.Code) user.MaxSessions = 10 user.UploadBandwidth = 128 - user.Permissions = []string{dataprovider.PermAny, dataprovider.PermDelete, dataprovider.PermDownload} + user.Permissions["/"] = []string{dataprovider.PermAny, dataprovider.PermDelete, dataprovider.PermDownload} userAsJSON = getUserAsJSON(t, user) req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer(userAsJSON)) rr = executeRequest(req) @@ -574,10 +586,10 @@ func TestBasicUserHandlingMock(t *testing.T) { if user.MaxSessions != updatedUser.MaxSessions || user.UploadBandwidth != updatedUser.UploadBandwidth { t.Errorf("Error modifying user actual: %v, %v", updatedUser.MaxSessions, updatedUser.UploadBandwidth) } - if len(updatedUser.Permissions) != 1 { + if len(updatedUser.Permissions["/"]) != 1 { t.Errorf("permissions other than any should be removed") } - if !utils.IsStringInSlice(dataprovider.PermAny, updatedUser.Permissions) { + if !utils.IsStringInSlice(dataprovider.PermAny, updatedUser.Permissions["/"]) { t.Errorf("permissions mismatch") } req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) @@ -614,7 +626,7 @@ func TestAddUserInvalidHomeDirMock(t *testing.T) { func TestAddUserInvalidPermsMock(t *testing.T) { user := getTestUser() - user.Permissions = []string{} + user.Permissions["/"] = []string{} userAsJSON := getUserAsJSON(t, user) req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) rr := executeRequest(req) @@ -627,6 +639,112 @@ func TestAddUserInvalidJsonMock(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr.Code) } +func TestUpdateUserMock(t *testing.T) { + user := getTestUser() + userAsJSON := getUserAsJSON(t, user) + req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err := render.DecodeJSON(rr.Body, &user) + if err != nil { + t.Errorf("Error get user: %v", err) + } + // permissions should not change if empty or nil + permissions := user.Permissions + user.Permissions = make(map[string][]string) + userAsJSON = getUserAsJSON(t, user) + req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer(userAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + req, _ = http.NewRequest(http.MethodGet, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + var updatedUser dataprovider.User + err = render.DecodeJSON(rr.Body, &updatedUser) + if err != nil { + t.Errorf("Error decoding updated user: %v", err) + } + for dir, perms := range permissions { + if actualPerms, ok := updatedUser.Permissions[dir]; ok { + for _, v := range actualPerms { + if !utils.IsStringInSlice(v, perms) { + t.Error("Permissions contents mismatch") + } + } + } else { + t.Error("Permissions directories mismatch") + } + } + req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) +} + +func TestUserPermissionsMock(t *testing.T) { + user := getTestUser() + user.Permissions = make(map[string][]string) + user.Permissions["/somedir"] = []string{dataprovider.PermAny} + userAsJSON := getUserAsJSON(t, user) + req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermAny} + user.Permissions[".."] = []string{dataprovider.PermAny} + userAsJSON = getUserAsJSON(t, user) + req, _ = http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermAny} + userAsJSON = getUserAsJSON(t, user) + req, _ = http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err := render.DecodeJSON(rr.Body, &user) + if err != nil { + t.Errorf("Error get user: %v", err) + } + user.Permissions["/somedir"] = []string{} + userAsJSON = getUserAsJSON(t, user) + req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer(userAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + delete(user.Permissions, "/somedir") + user.Permissions["not_abs_path"] = []string{dataprovider.PermAny} + userAsJSON = getUserAsJSON(t, user) + req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer(userAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + delete(user.Permissions, "not_abs_path") + user.Permissions["/somedir/../otherdir/"] = []string{dataprovider.PermListItems} + userAsJSON = getUserAsJSON(t, user) + req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer(userAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + req, _ = http.NewRequest(http.MethodGet, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + var updatedUser dataprovider.User + err = render.DecodeJSON(rr.Body, &updatedUser) + if err != nil { + t.Errorf("Error decoding updated user: %v", err) + } + if val, ok := updatedUser.Permissions["/otherdir"]; ok { + if !utils.IsStringInSlice(dataprovider.PermListItems, val) { + t.Error("expected permission list not found") + } + if len(val) != 1 { + t.Errorf("Unexpected number of permissions, expected 1, actual: %v", len(val)) + } + } else { + t.Errorf("expected dir not found in permissions") + } + req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) +} + func TestUpdateUserInvalidJsonMock(t *testing.T) { user := getTestUser() userAsJSON := getUserAsJSON(t, user) @@ -924,6 +1042,7 @@ func TestWebUserAddMock(t *testing.T) { form.Set("status", strconv.Itoa(user.Status)) form.Set("expiration_date", "") form.Set("permissions", "*") + form.Set("sub_dirs_permissions", "/subdir:list,download") // test invalid url escape req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -1050,6 +1169,7 @@ func TestWebUserUpdateMock(t *testing.T) { form.Set("upload_bandwidth", "0") form.Set("download_bandwidth", "0") form.Set("permissions", "*") + form.Set("sub_dirs_permissions", "/otherdir:list,upload") form.Set("status", strconv.Itoa(user.Status)) form.Set("expiration_date", "2020-01-01 00:00:00") req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode())) @@ -1142,13 +1262,15 @@ func waitTCPListening(address string) { } func getTestUser() dataprovider.User { - return dataprovider.User{ - Username: defaultUsername, - Password: defaultPassword, - HomeDir: filepath.Join(homeBasePath, defaultUsername), - Permissions: defaultPerms, - Status: 1, + user := dataprovider.User{ + Username: defaultUsername, + Password: defaultPassword, + HomeDir: filepath.Join(homeBasePath, defaultUsername), + Status: 1, } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = defaultPerms + return user } func getUserAsJSON(t *testing.T, user dataprovider.User) []byte { diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 5939af85..b22b49bf 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -69,13 +69,23 @@ func TestCheckUser(t *testing.T) { } expected.ID = 2 actual.ID = 2 - expected.Permissions = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload} - actual.Permissions = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks} + expected.Permissions = make(map[string][]string) + expected.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload} + actual.Permissions = make(map[string][]string) + actual.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks} err = checkUser(expected, actual) if err == nil { t.Errorf("Permissions are not equal") } - expected.Permissions = append(expected.Permissions, dataprovider.PermRename) + expected.Permissions["/"] = append(expected.Permissions["/"], dataprovider.PermRename) + err = checkUser(expected, actual) + if err == nil { + t.Errorf("Permissions are not equal") + } + expected.Permissions = make(map[string][]string) + expected.Permissions["/somedir"] = []string{dataprovider.PermAny} + actual.Permissions = make(map[string][]string) + actual.Permissions["/otherdir"] = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks} err = checkUser(expected, actual) if err == nil { t.Errorf("Permissions are not equal") @@ -85,6 +95,8 @@ func TestCheckUser(t *testing.T) { func TestCompareUserFields(t *testing.T) { expected := dataprovider.User{} actual := dataprovider.User{} + expected.Permissions = make(map[string][]string) + actual.Permissions = make(map[string][]string) expected.Username = "test" err := compareEqualsUserFields(expected, actual) if err == nil { @@ -127,7 +139,7 @@ func TestCompareUserFields(t *testing.T) { t.Errorf("QuotaFiles do not match") } expected.QuotaFiles = 0 - expected.Permissions = []string{dataprovider.PermCreateDirs} + expected.Permissions["/"] = []string{dataprovider.PermCreateDirs} err = compareEqualsUserFields(expected, actual) if err == nil { t.Errorf("Permissions are not equal") diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index f062c0d4..b44e7b4e 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.1 info: title: SFTPGo description: 'SFTPGo REST API' - version: 1.2.0 + version: 1.3.0 servers: - url: /api/v1 @@ -560,6 +560,15 @@ components: * `chmod` changing file or directory permissions is allowed * `chown` changing file or directory owner and group is allowed * `chtimes` changing file or directory access and modification time is allowed + DirPermissions: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/Permission' + minItems: 1 + minProperties: 1 + description: hash map with directory as key and an array of permissions as value. Directories must be absolute paths, permissions for root directory ("/") are required User: type: object properties: @@ -620,10 +629,11 @@ components: format: int32 description: quota as number of files. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed permissions: - type: array + type: object items: - $ref: '#/components/schemas/Permission' + $ref: '#/components/schemas/DirPermissions' minItems: 1 + example: {"/":["*"],"/somedir":["list","download"]} used_quota_size: type: integer format: int64 diff --git a/httpd/web.go b/httpd/web.go index 9c2a83e3..4a3be429 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -61,10 +61,12 @@ type connectionsPage struct { type userPage struct { basePage - IsAdd bool - User dataprovider.User - Error string - ValidPerms []string + IsAdd bool + User dataprovider.User + RootPerms []string + Error string + ValidPerms []string + RootDirPerms []string } type messagePage struct { @@ -156,26 +158,54 @@ func renderNotFoundPage(w http.ResponseWriter, err error) { func renderAddUserPage(w http.ResponseWriter, user dataprovider.User, error string) { data := userPage{ - basePage: getBasePageData("Add a new user", webUserPath), - IsAdd: true, - Error: error, - User: user, - ValidPerms: dataprovider.ValidPerms, + basePage: getBasePageData("Add a new user", webUserPath), + IsAdd: true, + Error: error, + User: user, + ValidPerms: dataprovider.ValidPerms, + RootDirPerms: user.GetPermissionsForPath("/"), } renderTemplate(w, templateUser, data) } func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error string) { data := userPage{ - basePage: getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, user.ID)), - IsAdd: false, - Error: error, - User: user, - ValidPerms: dataprovider.ValidPerms, + basePage: getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, user.ID)), + IsAdd: false, + Error: error, + User: user, + ValidPerms: dataprovider.ValidPerms, + RootDirPerms: user.GetPermissionsForPath("/"), } renderTemplate(w, templateUser, data) } +func getUserPermissionsFromPostFields(r *http.Request) map[string][]string { + permissions := make(map[string][]string) + permissions["/"] = r.Form["permissions"] + subDirsPermsValue := r.Form.Get("sub_dirs_permissions") + for _, v := range strings.Split(subDirsPermsValue, "\n") { + cleaned := strings.TrimSpace(v) + if len(cleaned) > 0 && strings.ContainsRune(cleaned, ':') { + dirPerms := strings.Split(cleaned, ":") + if len(dirPerms) > 1 { + dir := dirPerms[0] + perms := []string{} + for _, p := range strings.Split(dirPerms[1], ",") { + cleanedPerm := strings.TrimSpace(p) + if len(cleanedPerm) > 0 { + perms = append(perms, cleanedPerm) + } + } + if len(dir) > 0 && len(perms) > 0 { + permissions[dir] = perms + } + } + } + } + return permissions +} + func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { var user dataprovider.User err := r.ParseForm() @@ -238,7 +268,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { HomeDir: r.Form.Get("home_dir"), UID: uid, GID: gid, - Permissions: r.Form["permissions"], + Permissions: getUserPermissionsFromPostFields(r), MaxSessions: maxSessions, QuotaSize: quotaSize, QuotaFiles: quotaFiles, diff --git a/scripts/README.md b/scripts/README.md index 7d5c6920..1f99fbca 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -41,38 +41,47 @@ 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" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 +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 ``` Output: ```json { - "id": 5140, - "username": "test_username", - "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" - ], - "used_quota_size": 0, - "used_quota_files": 0, - "last_quota_update": 0, - "last_login": 0, + "download_bandwidth": 60, "expiration_date": 1546297200000, + "gid": 1000, + "home_dir": "/tmp/test_home_dir", + "id": 9576, + "last_login": 0, + "last_quota_update": 0, + "max_sessions": 2, + "permissions": { + "/": [ + "list", + "download", + "upload", + "delete", + "rename", + "create_dirs", + "overwrite" + ], + "/dir1": [ + "list", + "download" + ], + "/dir2": [ + "*" + ] + }, + "quota_files": 3, + "quota_size": 0, "status": 0, + "uid": 33, "upload_bandwidth": 100, - "download_bandwidth": 60 + "used_quota_files": 0, + "used_quota_size": 0, + "username": "test_username" } ``` @@ -81,7 +90,7 @@ Output: Command: ``` -python sftpgo_api_cli.py update-user 5140 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 "*" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" +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 "" ``` Output: @@ -99,32 +108,39 @@ Output: Command: ``` -python sftpgo_api_cli.py get-user-by-id 5140 +python sftpgo_api_cli.py get-user-by-id 9576 ``` Output: ```json { - "id": 5140, - "username": "test_username", - "home_dir": "/tmp/test_home_dir", - "uid": 0, - "gid": 33, - "max_sessions": 2, - "quota_size": 0, - "quota_files": 4, - "permissions": [ - "*" - ], - "used_quota_size": 0, - "used_quota_files": 0, - "last_quota_update": 0, - "last_login": 0, + "download_bandwidth": 80, "expiration_date": 0, + "gid": 33, + "home_dir": "/tmp/test_home_dir", + "id": 9576, + "last_login": 0, + "last_quota_update": 0, + "max_sessions": 3, + "permissions": { + "/": [ + "*" + ], + "/dir1": [ + "list", + "download", + "create_symlinks" + ] + }, + "quota_files": 4, + "quota_size": 0, "status": 1, + "uid": 0, "upload_bandwidth": 90, - "download_bandwidth": 80 + "used_quota_files": 0, + "used_quota_size": 0, + "username": "test_username" } ``` @@ -141,25 +157,32 @@ Output: ```json [ { - "id": 5140, - "username": "test_username", - "home_dir": "/tmp/test_home_dir", - "uid": 0, - "gid": 33, - "max_sessions": 2, - "quota_size": 0, - "quota_files": 4, - "permissions": [ - "*" - ], - "used_quota_size": 0, - "used_quota_files": 0, - "last_quota_update": 0, - "last_login": 0, + "download_bandwidth": 80, "expiration_date": 0, + "gid": 33, + "home_dir": "/tmp/test_home_dir", + "id": 9576, + "last_login": 0, + "last_quota_update": 0, + "max_sessions": 3, + "permissions": { + "/": [ + "*" + ], + "/dir1": [ + "list", + "download", + "create_symlinks" + ] + }, + "quota_files": 4, + "quota_size": 0, "status": 1, + "uid": 0, "upload_bandwidth": 90, - "download_bandwidth": 80 + "used_quota_files": 0, + "used_quota_size": 0, + "username": "test_username" } ] ``` @@ -177,23 +200,23 @@ Output: ```json [ { - "username": "test_username", - "connection_id": "76a11b22260ee4249328df28bef34dc64c70f7c097db52159fc24049eeb0e32c", - "client_version": "SSH-2.0-OpenSSH_8.0", - "remote_address": "127.0.0.1:41622", - "connection_time": 1564696137971, - "last_activity": 1564696159605, - "protocol": "SFTP", - "ssh_command": "", "active_transfers": [ { + "last_activity": 1577197485561, "operation_type": "upload", - "path": "/test_upload.gz", - "start_time": 1564696149783, - "size": 1146880, - "last_activity": 1564696159605 + "path": "/test_upload.tar.gz", + "size": 1540096, + "start_time": 1577197471372 } - ] + ], + "client_version": "SSH-2.0-OpenSSH_8.1", + "connection_id": "f82cfec6a391ad673edd4ae9a144f32ccb59456139f8e1185b070134fffbab7c", + "connection_time": 1577197433003, + "last_activity": 1577197485561, + "protocol": "SFTP", + "remote_address": "127.0.0.1:43714", + "ssh_command": "", + "username": "test_username" } ] ``` @@ -203,7 +226,7 @@ Output: Command: ``` -python sftpgo_api_cli.py close-connection 76a11b22260ee4249328df28bef34dc64c70f7c097db52159fc24049eeb0e32c +python sftpgo_api_cli.py close-connection f82cfec6a391ad673edd4ae9a144f32ccb59456139f8e1185b070134fffbab7c ``` Output: @@ -247,7 +270,7 @@ Output: Command: ``` -python sftpgo_api_cli.py delete-user 5140 +python sftpgo_api_cli.py delete-user 9576 ``` Output: @@ -272,9 +295,9 @@ Output: ```json { - "version": "0.9.0-dev", - "build_date": "2019-08-08T08:11:34Z", - "commit_hash": "4f4489d-dirty" + "build_date": "2019-12-24T14:17:47Z", + "commit_hash": "f8fd5c0-dirty", + "version": "0.9.4-dev" } ``` diff --git a/scripts/sftpgo_api_cli.py b/scripts/sftpgo_api_cli.py index 068d6f00..b5dcacee 100755 --- a/scripts/sftpgo_api_cli.py +++ b/scripts/sftpgo_api_cli.py @@ -1,8 +1,8 @@ #!/usr/bin/env python -from datetime import datetime - import argparse +from datetime import datetime import json + import requests try: @@ -60,7 +60,7 @@ class SFTPGoApiRequests: print(r.text) def buildUserObject(self, user_id=0, username="", password="", public_keys="", home_dir="", uid=0, - gid=0, max_sessions=0, quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0, + gid=0, max_sessions=0, quota_size=0, quota_files=0, permissions={}, upload_bandwidth=0, download_bandwidth=0, status=1, expiration_date=0): user = {"id":user_id, "username":username, "uid":uid, "gid":gid, "max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files, @@ -76,6 +76,23 @@ class SFTPGoApiRequests: user.update({"permissions":permissions}) return user + def build_permissions(self, root_perms, subdirs_perms): + permissions = {} + if root_perms: + permissions.update({"/":root_perms}) + for p in subdirs_perms: + if ":" in p: + directory = None + values = [] + for value in p.split(":"): + if directory is None: + directory = value + else: + values = [v.strip() for v in value.split(",") if v.strip()] + if directory and values: + permissions.update({directory:values}) + return permissions + def getUsers(self, limit=100, offset=0, order="ASC", username=""): r = requests.get(self.userPath, params={"limit":limit, "offset":offset, "order":order, "username":username}, auth=self.auth, verify=self.verify) @@ -86,18 +103,20 @@ class SFTPGoApiRequests: self.printResponse(r) def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0, - quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0, download_bandwidth=0, status=1, - expiration_date=0): + quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1, + expiration_date=0, subdirs_permissions=[]): u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions, - quota_size, quota_files, permissions, upload_bandwidth, download_bandwidth, status, expiration_date) + quota_size, quota_files, self.build_permissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, + status, expiration_date) r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify) self.printResponse(r) def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0, - max_sessions=0, quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0, - download_bandwidth=0, status=1, expiration_date=0): + max_sessions=0, quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, + download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[]): u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions, - quota_size, quota_files, permissions, upload_bandwidth, download_bandwidth, status, expiration_date) + quota_size, quota_files, self.build_permissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth, + status, expiration_date) r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -160,7 +179,10 @@ def addCommonUserArguments(parser): parser.add_argument('-F', '--quota-files', type=int, default=0, help="default: %(default)s") parser.add_argument('-G', '--permissions', type=str, nargs='+', default=[], choices=['*', 'list', 'download', 'upload', 'overwrite', 'delete', 'rename', 'create_dirs', - 'create_symlinks', 'chmod', 'chown', 'chtimes'], help='Default: %(default)s') + 'create_symlinks', 'chmod', 'chown', 'chtimes'], help='Permissions for the root directory ' + +'(/). Default: %(default)s') + parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. ' + +'For example: "/somedir:list,download" "/otherdir/subdir:*" Default: %(default)s') parser.add_argument('-U', '--upload-bandwidth', type=int, default=0, help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s') parser.add_argument('-D', '--download-bandwidth', type=int, default=0, @@ -237,11 +259,12 @@ if __name__ == '__main__': if args.command == 'add-user': api.addUser(args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, args.download_bandwidth, - args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date)) + args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions) 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, - args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date)) + args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), + args.subdirs_permissions) elif args.command == 'delete-user': api.deleteUser(args.id) elif args.command == 'get-users': diff --git a/sftpd/handler.go b/sftpd/handler.go index d352e641..e3cbc455 100644 --- a/sftpd/handler.go +++ b/sftpd/handler.go @@ -51,14 +51,13 @@ func (c Connection) Log(level logger.LogLevel, sender string, format string, v . func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) { updateConnectionActivity(c.ID) - if !c.User.HasPerm(dataprovider.PermDownload) { - return nil, sftp.ErrSSHFxPermissionDenied - } - p, err := c.buildPath(request.Filepath) if err != nil { return nil, getSFTPErrorFromOSError(err) } + if !c.User.HasPerm(dataprovider.PermDownload, filepath.Dir(p)) { + return nil, sftp.ErrSSHFxPermissionDenied + } c.lock.Lock() defer c.lock.Unlock() @@ -98,10 +97,6 @@ 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.HasPerm(dataprovider.PermUpload) { - return nil, sftp.ErrSSHFxPermissionDenied - } - p, err := c.buildPath(request.Filepath) if err != nil { return nil, getSFTPErrorFromOSError(err) @@ -119,6 +114,9 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) { // If the file doesn't exist we need to create it, as well as the directory pathway // leading up to where that file will be created. if os.IsNotExist(statErr) { + if !c.User.HasPerm(dataprovider.PermUpload, filepath.Dir(p)) { + return nil, sftp.ErrSSHFxPermissionDenied + } return c.handleSFTPUploadToNewFile(p, filePath) } @@ -133,7 +131,7 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) { return nil, sftp.ErrSSHFxOpUnsupported } - if !c.User.HasPerm(dataprovider.PermOverwrite) { + if !c.User.HasPerm(dataprovider.PermOverwrite, filepath.Dir(filePath)) { return nil, sftp.ErrSSHFxPermissionDenied } @@ -212,7 +210,7 @@ func (c Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) { switch request.Method { case "List": - if !c.User.HasPerm(dataprovider.PermListItems) { + if !c.User.HasPerm(dataprovider.PermListItems, p) { return nil, sftp.ErrSSHFxPermissionDenied } @@ -226,7 +224,7 @@ func (c Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) { return listerAt(files), nil case "Stat": - if !c.User.HasPerm(dataprovider.PermListItems) { + if !c.User.HasPerm(dataprovider.PermListItems, filepath.Dir(p)) { return nil, sftp.ErrSSHFxPermissionDenied } @@ -266,9 +264,15 @@ func (c Connection) handleSFTPSetstat(path string, request *sftp.Request) error c.ClientVersion) return sftp.ErrSSHFxBadMessage } + pathForPerms := path + if fi, err := os.Lstat(path); err == nil { + if fi.IsDir() { + pathForPerms = filepath.Dir(path) + } + } attrFlags := request.AttrFlags() if attrFlags.Permissions { - if !c.User.HasPerm(dataprovider.PermChmod) { + if !c.User.HasPerm(dataprovider.PermChmod, pathForPerms) { return sftp.ErrSSHFxPermissionDenied } fileMode := request.Attributes().FileMode() @@ -279,7 +283,7 @@ func (c Connection) handleSFTPSetstat(path string, request *sftp.Request) error logger.CommandLog(chmodLogSender, path, "", c.User.Username, fileMode.String(), c.ID, c.protocol, -1, -1, "", "", "") return nil } else if attrFlags.UidGid { - if !c.User.HasPerm(dataprovider.PermChown) { + if !c.User.HasPerm(dataprovider.PermChown, pathForPerms) { return sftp.ErrSSHFxPermissionDenied } uid := int(request.Attributes().UID) @@ -291,7 +295,7 @@ func (c Connection) handleSFTPSetstat(path string, request *sftp.Request) error logger.CommandLog(chownLogSender, path, "", c.User.Username, "", c.ID, c.protocol, uid, gid, "", "", "") return nil } else if attrFlags.Acmodtime { - if !c.User.HasPerm(dataprovider.PermChtimes) { + if !c.User.HasPerm(dataprovider.PermChtimes, pathForPerms) { return sftp.ErrSSHFxPermissionDenied } dateFormat := "2006-01-02T15:04:05" // YYYY-MM-DDTHH:MM:SS @@ -312,7 +316,7 @@ func (c Connection) handleSFTPSetstat(path string, request *sftp.Request) error } func (c Connection) handleSFTPRename(sourcePath string, targetPath string) error { - if !c.User.HasPerm(dataprovider.PermRename) { + if !c.User.HasPerm(dataprovider.PermRename, filepath.Dir(targetPath)) { return sftp.ErrSSHFxPermissionDenied } if err := os.Rename(sourcePath, targetPath); err != nil { @@ -325,7 +329,7 @@ func (c Connection) handleSFTPRename(sourcePath string, targetPath string) error } func (c Connection) handleSFTPRmdir(path string) error { - if !c.User.HasPerm(dataprovider.PermDelete) { + if !c.User.HasPerm(dataprovider.PermDelete, filepath.Dir(path)) { return sftp.ErrSSHFxPermissionDenied } @@ -350,7 +354,7 @@ func (c Connection) handleSFTPRmdir(path string) error { } func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string) error { - if !c.User.HasPerm(dataprovider.PermCreateSymlinks) { + if !c.User.HasPerm(dataprovider.PermCreateSymlinks, filepath.Dir(targetPath)) { return sftp.ErrSSHFxPermissionDenied } if err := os.Symlink(sourcePath, targetPath); err != nil { @@ -363,7 +367,7 @@ func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string) erro } func (c Connection) handleSFTPMkdir(path string) error { - if !c.User.HasPerm(dataprovider.PermCreateDirs) { + if !c.User.HasPerm(dataprovider.PermCreateDirs, filepath.Dir(path)) { return sftp.ErrSSHFxPermissionDenied } if err := os.Mkdir(path, 0777); err != nil { @@ -377,7 +381,7 @@ func (c Connection) handleSFTPMkdir(path string) error { } func (c Connection) handleSFTPRemove(path string) error { - if !c.User.HasPerm(dataprovider.PermDelete) { + if !c.User.HasPerm(dataprovider.PermDelete, filepath.Dir(path)) { return sftp.ErrSSHFxPermissionDenied } diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index 5cb4292c..70379acf 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -192,7 +192,8 @@ func TestSFTPCmdTargetPath(t *testing.T) { u := dataprovider.User{} u.HomeDir = "home_rel_path" u.Username = "test" - u.Permissions = []string{"*"} + u.Permissions = make(map[string][]string) + u.Permissions["/"] = []string{dataprovider.PermAny} connection := Connection{ User: u, } @@ -242,7 +243,8 @@ func TestSFTPGetUsedQuota(t *testing.T) { u.Username = "test_invalid_user" u.QuotaSize = 4096 u.QuotaFiles = 1 - u.Permissions = []string{"*"} + u.Permissions = make(map[string][]string) + u.Permissions["/"] = []string{dataprovider.PermAny} connection := Connection{ User: u, } @@ -323,12 +325,13 @@ func TestSSHCommandErrors(t *testing.T) { server, client := net.Pipe() defer server.Close() defer client.Close() + user := dataprovider.User{} + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermAny} connection := Connection{ channel: &mockSSHChannel, netConn: client, - User: dataprovider.User{ - Permissions: []string{dataprovider.PermAny}, - }, + User: user, } cmd := sshCommand{ command: "md5sum", @@ -366,12 +369,13 @@ func TestSSHCommandErrors(t *testing.T) { } cmd.connection.User.QuotaFiles = 0 cmd.connection.User.UsedQuotaFiles = 0 - cmd.connection.User.Permissions = []string{dataprovider.PermListItems} + cmd.connection.User.Permissions = make(map[string][]string) + cmd.connection.User.Permissions["/"] = []string{dataprovider.PermListItems} err = cmd.handle() if err != errPermissionDenied { t.Errorf("unexpected error: %v", err) } - cmd.connection.User.Permissions = []string{dataprovider.PermAny} + cmd.connection.User.Permissions["/"] = []string{dataprovider.PermAny} cmd.command = "invalid_command" command, err := cmd.getSystemCommand() if err != nil { @@ -417,11 +421,13 @@ func TestSSHCommandQuotaScan(t *testing.T) { server, client := net.Pipe() defer server.Close() defer client.Close() + permissions := make(map[string][]string) + permissions["/"] = []string{dataprovider.PermAny} connection := Connection{ channel: &mockSSHChannel, netConn: client, User: dataprovider.User{ - Permissions: []string{dataprovider.PermAny}, + Permissions: permissions, QuotaFiles: 1, HomeDir: "invalid_path", }, @@ -438,9 +444,11 @@ func TestSSHCommandQuotaScan(t *testing.T) { } func TestRsyncOptions(t *testing.T) { + permissions := make(map[string][]string) + permissions["/"] = []string{dataprovider.PermAny} conn := Connection{ User: dataprovider.User{ - Permissions: []string{dataprovider.PermAny}, + Permissions: permissions, HomeDir: os.TempDir(), }, } @@ -456,11 +464,12 @@ func TestRsyncOptions(t *testing.T) { if !utils.IsStringInSlice("--safe-links", cmd.cmd.Args) { t.Errorf("--safe-links must be added if the user has the create symlinks permission") } + permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, + dataprovider.PermListItems, dataprovider.PermOverwrite, dataprovider.PermDelete, dataprovider.PermRename} conn = Connection{ User: dataprovider.User{ - Permissions: []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, - dataprovider.PermListItems, dataprovider.PermOverwrite, dataprovider.PermDelete, dataprovider.PermRename}, - HomeDir: os.TempDir(), + Permissions: permissions, + HomeDir: os.TempDir(), }, } sshCmd = sshCommand{ @@ -491,18 +500,20 @@ func TestSystemCommandErrors(t *testing.T) { server, client := net.Pipe() defer server.Close() defer client.Close() + permissions := make(map[string][]string) + permissions["/"] = []string{dataprovider.PermAny} connection := Connection{ channel: &mockSSHChannel, netConn: client, User: dataprovider.User{ - Permissions: []string{dataprovider.PermAny}, + Permissions: permissions, HomeDir: os.TempDir(), }, } sshCmd := sshCommand{ command: "ls", connection: connection, - args: []string{}, + args: []string{"/"}, } systemCmd, err := sshCmd.getSystemCommand() if err != nil { @@ -929,7 +940,8 @@ func TestSCPCreateDirs(t *testing.T) { u := dataprovider.User{} u.HomeDir = "home_rel_path" u.Username = "test" - u.Permissions = []string{"*"} + u.Permissions = make(map[string][]string) + u.Permissions["/"] = []string{dataprovider.PermAny} mockSSHChannel := MockChannel{ Buffer: bytes.NewBuffer(buf), StdErrBuffer: bytes.NewBuffer(stdErrBuf), diff --git a/sftpd/scp.go b/sftpd/scp.go index 582a29fe..7d9f624f 100644 --- a/sftpd/scp.go +++ b/sftpd/scp.go @@ -114,19 +114,18 @@ func (c *scpCommand) handleRecursiveUpload() error { func (c *scpCommand) handleCreateDir(dirPath string) error { updateConnectionActivity(c.connection.ID) - if !c.connection.User.HasPerm(dataprovider.PermCreateDirs) { - err := fmt.Errorf("Permission denied") - c.connection.Log(logger.LevelWarn, logSenderSCP, "error creating dir: %#v, permission denied", dirPath) - c.sendErrorMessage(err.Error()) - return err - } - p, err := c.connection.buildPath(dirPath) if err != nil { c.connection.Log(logger.LevelWarn, logSenderSCP, "error creating dir: %#v, invalid file path, err: %v", dirPath, err) c.sendErrorMessage(err.Error()) return err } + if !c.connection.User.HasPerm(dataprovider.PermCreateDirs, filepath.Dir(p)) { + err := fmt.Errorf("Permission denied") + c.connection.Log(logger.LevelWarn, logSenderSCP, "error creating dir: %#v, permission denied", dirPath) + c.sendErrorMessage(err.Error()) + return err + } err = c.createDir(p) if err != nil { @@ -188,15 +187,6 @@ func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead i return err } - if _, err := os.Stat(filepath.Dir(requestPath)); os.IsNotExist(err) { - if !c.connection.User.HasPerm(dataprovider.PermCreateDirs) { - err := fmt.Errorf("Permission denied") - c.connection.Log(logger.LevelWarn, logSenderSCP, "error uploading file: %#v, permission denied", requestPath) - c.sendErrorMessage(err.Error()) - return err - } - } - file, err := os.Create(filePath) if err != nil { c.connection.Log(logger.LevelError, logSenderSCP, "error creating file %#v: %v", requestPath, err) @@ -231,12 +221,6 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error var err error updateConnectionActivity(c.connection.ID) - if !c.connection.User.HasPerm(dataprovider.PermUpload) { - err := fmt.Errorf("Permission denied") - c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot upload file: %#v, permission denied", uploadFilePath) - c.sendErrorMessage(err.Error()) - return err - } p, err := c.connection.buildPath(uploadFilePath) if err != nil { @@ -250,6 +234,12 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error } stat, statErr := os.Stat(p) if os.IsNotExist(statErr) { + if !c.connection.User.HasPerm(dataprovider.PermUpload, filepath.Dir(p)) { + err := fmt.Errorf("Permission denied") + c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot upload file: %#v, permission denied", uploadFilePath) + c.sendErrorMessage(err.Error()) + return err + } return c.handleUploadFile(p, filePath, sizeToRead, true) } @@ -266,7 +256,7 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error return err } - if !c.connection.User.HasPerm(dataprovider.PermOverwrite) { + if !c.connection.User.HasPerm(dataprovider.PermOverwrite, filePath) { err := fmt.Errorf("Permission denied") c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot overwrite file: %#v, permission denied", uploadFilePath) c.sendErrorMessage(err.Error()) @@ -425,12 +415,6 @@ func (c *scpCommand) handleDownload(filePath string) error { updateConnectionActivity(c.connection.ID) - if !c.connection.User.HasPerm(dataprovider.PermDownload) { - err := fmt.Errorf("Permission denied") - c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading file: %#v, permission denied", filePath) - c.sendErrorMessage(err.Error()) - return err - } p, err := c.connection.buildPath(filePath) if err != nil { err := fmt.Errorf("Invalid file path") @@ -447,10 +431,23 @@ func (c *scpCommand) handleDownload(filePath string) error { } if stat.IsDir() { + if !c.connection.User.HasPerm(dataprovider.PermDownload, p) { + err := fmt.Errorf("Permission denied") + c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading dir: %#v, permission denied", filePath) + c.sendErrorMessage(err.Error()) + return err + } err = c.handleRecursiveDownload(p, stat) return err } + if !c.connection.User.HasPerm(dataprovider.PermDownload, filepath.Dir(p)) { + err := fmt.Errorf("Permission denied") + c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading dir: %#v, permission denied", filePath) + c.sendErrorMessage(err.Error()) + return err + } + file, err := os.Open(p) if err != nil { c.connection.Log(logger.LevelError, logSenderSCP, "could not open file %#v for reading: %v", p, err) diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 9f468d39..6f6da659 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -196,7 +196,7 @@ func TestMain(m *testing.M) { waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort)) exitCode := m.Run() - //os.Remove(logfilePath) + os.Remove(logfilePath) os.Remove(loginBannerFile) os.Remove(pubKeyPath) os.Remove(privateKeyPath) @@ -1395,6 +1395,7 @@ func TestMissingFile(t *testing.T) { if err == nil { t.Errorf("download missing file must fail") } + os.Remove(localDownloadPath) } _, err = httpd.RemoveUser(user, http.StatusOK) if err != nil { @@ -1697,7 +1698,7 @@ func TestPasswordsHashSHA512Crypt(t *testing.T) { func TestPermList(t *testing.T) { usePubKey := true u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, + u.Permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1728,7 +1729,7 @@ func TestPermList(t *testing.T) { func TestPermDownload(t *testing.T) { usePubKey := true u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1773,7 +1774,7 @@ func TestPermDownload(t *testing.T) { func TestPermUpload(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermDelete, dataprovider.PermRename, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1808,7 +1809,7 @@ func TestPermUpload(t *testing.T) { func TestPermOverwrite(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1847,7 +1848,7 @@ func TestPermOverwrite(t *testing.T) { func TestPermDelete(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermRename, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1886,7 +1887,7 @@ func TestPermDelete(t *testing.T) { func TestPermRename(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1929,7 +1930,7 @@ func TestPermRename(t *testing.T) { func TestPermCreateDirs(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1956,7 +1957,7 @@ func TestPermCreateDirs(t *testing.T) { func TestPermSymlink(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -1999,7 +2000,7 @@ func TestPermSymlink(t *testing.T) { func TestPermChmod(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChown, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -2042,7 +2043,7 @@ func TestPermChmod(t *testing.T) { func TestPermChown(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChtimes} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -2085,7 +2086,7 @@ func TestPermChown(t *testing.T) { func TestPermChtimes(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, dataprovider.PermRename, dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermOverwrite, dataprovider.PermChmod, dataprovider.PermChown} user, _, err := httpd.AddUser(u, http.StatusOK) @@ -2125,6 +2126,396 @@ func TestPermChtimes(t *testing.T) { os.RemoveAll(user.GetHomeDir()) } +func TestSubDirsUploads(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.Permissions["/"] = []string{dataprovider.PermAny} + u.Permissions["/subdir"] = []string{dataprovider.PermChtimes, dataprovider.PermDownload} + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add 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 = client.Mkdir("subdir") + if err != nil { + t.Errorf("unexpected mkdir error: %v", err) + } + testFileName := "test_file.dat" + testFileNameSub := "/subdir/test_file_dat" + testFilePath := filepath.Join(homeBasePath, testFileName) + testFileSize := int64(65535) + 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) + } + err = sftpUploadFile(testFilePath, testFileNameSub, testFileSize, client) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected upload error: %v", err) + } + err = client.Symlink(testFileName, testFileNameSub+".link") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected upload error: %v", err) + } + err = client.Symlink(testFileName, testFileName+".link") + if err != nil { + t.Errorf("symlink error: %v", err) + } + err = client.Rename(testFileName, testFileNameSub+".rename") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected rename error: %v", err) + } + err = client.Rename(testFileName, testFileName+".rename") + if err != nil { + t.Errorf("rename error: %v", err) + } + err = client.Remove(testFileNameSub) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected upload error: %v", err) + } + err = client.Remove(testFileName + ".rename") + if err != nil { + t.Errorf("remove error: %v", err) + } + os.Remove(testFilePath) + } + httpd.RemoveUser(user, http.StatusOK) + os.RemoveAll(user.GetHomeDir()) +} + +func TestSubDirsOverwrite(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.Permissions["/"] = []string{dataprovider.PermAny} + u.Permissions["/subdir"] = []string{dataprovider.PermOverwrite, dataprovider.PermListItems} + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add user: %v", err) + } + client, err := getSftpClient(user, usePubKey) + if err != nil { + t.Errorf("unable to create sftp client: %v", err) + } else { + defer client.Close() + testFileName := "/subdir/test_file.dat" + testFilePath := filepath.Join(homeBasePath, "test_file.dat") + testFileSFTPPath := filepath.Join(u.GetHomeDir(), "subdir", "test_file.dat") + testFileSize := int64(65535) + err = createTestFile(testFilePath, testFileSize) + if err != nil { + t.Errorf("unable to create test file: %v", err) + } + err = createTestFile(testFileSFTPPath, 16384) + if err != nil { + t.Errorf("unable to create test file: %v", err) + } + err = sftpUploadFile(testFilePath, testFileName+".new", testFileSize, client) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected upload error: %v", err) + } + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + if err != nil { + t.Errorf("unexpected overwrite error: %v", err) + } + os.Remove(testFilePath) + } + httpd.RemoveUser(user, http.StatusOK) + os.RemoveAll(user.GetHomeDir()) +} + +func TestSubDirsDownloads(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.Permissions["/"] = []string{dataprovider.PermAny} + u.Permissions["/subdir"] = []string{dataprovider.PermChmod, dataprovider.PermUpload, dataprovider.PermListItems} + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add 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 = client.Mkdir("subdir") + if err != nil { + t.Errorf("unexpected mkdir error: %v", err) + } + testFileName := "/subdir/test_file.dat" + testFilePath := filepath.Join(homeBasePath, "test_file.dat") + testFileSize := int64(65535) + 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) + } + localDownloadPath := filepath.Join(homeBasePath, "test_download.dat") + err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected upload error: %v", err) + } + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected overwrite error: %v", err) + } + err = client.Chtimes(testFileName, time.Now(), time.Now()) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected chtimes error: %v", err) + } + err = client.Rename(testFileName, testFileName+".rename") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected rename error: %v", err) + } + err = client.Symlink(testFileName, testFileName+".link") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected symlink error: %v", err) + } + err = client.Remove(testFileName) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected remove error: %v", err) + } + os.Remove(localDownloadPath) + os.Remove(testFilePath) + } + httpd.RemoveUser(user, http.StatusOK) + os.RemoveAll(user.GetHomeDir()) +} + +func TestPermsSubDirsSetstat(t *testing.T) { + // for setstat we check the parent dir permission if the requested path is a dir + // otherwise the path permission + usePubKey := true + u := getTestUser(usePubKey) + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermCreateDirs} + u.Permissions["/subdir"] = []string{dataprovider.PermAny} + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add 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 = client.Mkdir("subdir") + if err != nil { + t.Errorf("unexpected mkdir error: %v", err) + } + testFileName := "/subdir/test_file.dat" + testFilePath := filepath.Join(homeBasePath, "test_file.dat") + testFileSize := int64(65535) + 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) + } + err = client.Chtimes("/subdir/", time.Now(), time.Now()) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected chtimes error: %v", err) + } + err = client.Chtimes("subdir/", time.Now(), time.Now()) + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected chtimes error: %v", err) + } + err = client.Chtimes(testFileName, time.Now(), time.Now()) + if err != nil { + t.Errorf("unexpected chtimes error: %v", err) + } + os.Remove(testFilePath) + } + httpd.RemoveUser(user, http.StatusOK) + os.RemoveAll(user.GetHomeDir()) +} + +func TestPermsSubDirsCommands(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.Permissions["/"] = []string{dataprovider.PermAny} + u.Permissions["/subdir"] = []string{dataprovider.PermDownload, dataprovider.PermUpload} + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add user: %v", err) + } + client, err := getSftpClient(user, usePubKey) + if err != nil { + t.Errorf("unable to create sftp client: %v", err) + } else { + defer client.Close() + client.Mkdir("subdir") + acmodTime := time.Now() + err = client.Chtimes("/subdir", acmodTime, acmodTime) + if err != nil { + t.Errorf("unexpected chtimes error: %v", err) + } + _, err = client.Stat("/subdir") + if err != nil { + t.Errorf("unexpected stat error: %v", err) + } + _, err = client.ReadDir("/") + if err != nil { + t.Errorf("unexpected readdir error: %v", err) + } + _, err = client.ReadDir("/subdir") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected error: %v", err) + } + err = client.RemoveDirectory("/subdir/dir") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected error: %v", err) + } + err = client.Mkdir("/subdir/dir") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected error: %v", err) + } + client.Mkdir("/otherdir") + err = client.Rename("/otherdir", "/subdir/otherdir") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected error: %v", err) + } + err = client.Symlink("/otherdir", "/subdir/otherdir") + if !strings.Contains(err.Error(), "Permission Denied") { + t.Errorf("unexpected error: %v", err) + } + err = client.Symlink("/otherdir", "/otherdir_link") + if err != nil { + t.Errorf("unexpected rename dir error: %v", err) + } + err = client.Rename("/otherdir", "/otherdir1") + if err != nil { + t.Errorf("unexpected rename dir error: %v", err) + } + err = client.RemoveDirectory("/subdir") + if err != nil { + t.Errorf("unexpected remove dir error: %v", err) + } + } + httpd.RemoveUser(user, http.StatusOK) + os.RemoveAll(user.GetHomeDir()) +} + +func TestRelativePaths(t *testing.T) { + user := getTestUser(true) + path := filepath.Join(user.HomeDir, "/") + rel := user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "//") + rel = user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "../..") + rel = user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "../../../../../") + rel = user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "/..") + rel = user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "/../../../..") + rel = user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "") + rel = user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, ".") + rel = user.GetRelativePath(path) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "somedir") + rel = user.GetRelativePath(path) + if rel != "/somedir" { + t.Errorf("Unexpected relative path: %v", rel) + } + path = filepath.Join(user.HomeDir, "/somedir/subdir") + rel = user.GetRelativePath(path) + if rel != "/somedir/subdir" { + t.Errorf("Unexpected relative path: %v", rel) + } +} + +func TestUserPerms(t *testing.T) { + user := getTestUser(true) + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermListItems} + user.Permissions["/p"] = []string{dataprovider.PermDelete} + user.Permissions["/p/1"] = []string{dataprovider.PermDownload, dataprovider.PermUpload} + user.Permissions["/p/2"] = []string{dataprovider.PermCreateDirs} + user.Permissions["/p/3"] = []string{dataprovider.PermChmod} + user.Permissions["/p/3/4"] = []string{dataprovider.PermChtimes} + user.Permissions["/tmp"] = []string{dataprovider.PermRename} + if !user.HasPerm(dataprovider.PermListItems, filepath.Join(user.HomeDir, "/")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermListItems, filepath.Join(user.HomeDir, ".")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermListItems, filepath.Join(user.HomeDir, "")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermListItems, filepath.Join(user.HomeDir, "../")) { + t.Error("expected permission not found") + } + // path p and /p are the same + if !user.HasPerm(dataprovider.PermDelete, filepath.Join(user.HomeDir, "/p")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermDownload, filepath.Join(user.HomeDir, "/p/1")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermCreateDirs, filepath.Join(user.HomeDir, "p/2")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermChmod, filepath.Join(user.HomeDir, "/p/3")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermChtimes, filepath.Join(user.HomeDir, "p/3/4")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermChtimes, filepath.Join(user.HomeDir, "p/3/4/../4")) { + t.Error("expected permission not found") + } + // undefined paths have permissions of the nearest path + if !user.HasPerm(dataprovider.PermListItems, filepath.Join(user.HomeDir, "/p34")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermListItems, filepath.Join(user.HomeDir, "/p34/p1/file.dat")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermChtimes, filepath.Join(user.HomeDir, "/p/3/4/5/6")) { + t.Error("expected permission not found") + } + if !user.HasPerm(dataprovider.PermDownload, filepath.Join(user.HomeDir, "/p/1/test/file.dat")) { + t.Error("expected permission not found") + } +} + func TestSSHCommands(t *testing.T) { usePubKey := false user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK) @@ -2208,6 +2599,21 @@ func TestSSHFileHash(t *testing.T) { if err != nil { t.Errorf("file upload error: %v", err) } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermUpload} + _, _, err = httpd.UpdateUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to update user: %v", err) + } + _, err = runSSHCommand("sha512sum "+testFileName, user, usePubKey) + if err == nil { + t.Errorf("hash command with no list permission must fail") + } + user.Permissions["/"] = []string{dataprovider.PermAny} + _, _, err = httpd.UpdateUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to update user: %v", err) + } initialHash, err := computeHashForFile(sha512.New(), testFilePath) if err != nil { t.Errorf("error computing file hash: %v", err) @@ -2523,13 +2929,57 @@ func TestSCPRecursive(t *testing.T) { } } +func TestSCPPermsSubDirs(t *testing.T) { + if len(scpPath) == 0 { + t.Skip("scp command not found, unable to execute this test") + } + usePubKey := true + u := getTestUser(usePubKey) + u.Permissions["/"] = []string{dataprovider.PermAny} + u.Permissions["/somedir"] = []string{dataprovider.PermListItems, dataprovider.PermUpload} + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add user: %v", err) + } + localPath := filepath.Join(homeBasePath, "scp_download.dat") + subPath := filepath.Join(user.GetHomeDir(), "somedir") + testFileSize := int64(65535) + os.MkdirAll(subPath, 0777) + remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, "/somedir") + err = scpDownload(localPath, remoteDownPath, false, true) + if err == nil { + t.Error("download a dir with no permissions must fail") + } + os.Remove(subPath) + err = createTestFile(subPath, testFileSize) + if err != nil { + t.Errorf("unable to create test file: %v", err) + } + err = scpDownload(localPath, remoteDownPath, false, false) + if err != nil { + t.Errorf("unexpected download error: %v", err) + } + os.Chmod(subPath, 0001) + err = scpDownload(localPath, remoteDownPath, false, false) + if err == nil { + t.Error("download a file with no system permissions must fail") + } + os.Chmod(subPath, 0755) + os.Remove(localPath) + os.RemoveAll(user.GetHomeDir()) + _, err = httpd.RemoveUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to remove user: %v", err) + } +} + func TestSCPPermCreateDirs(t *testing.T) { if len(scpPath) == 0 { t.Skip("scp command not found, unable to execute this test") } usePubKey := true u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermDownload, dataprovider.PermUpload} + u.Permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermUpload} user, _, err := httpd.AddUser(u, http.StatusOK) if err != nil { t.Errorf("unable to add user: %v", err) @@ -2551,7 +3001,7 @@ func TestSCPPermCreateDirs(t *testing.T) { remoteUpPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, "/tmp/") err = scpUpload(testFilePath, remoteUpPath, true, false) if err == nil { - t.Errorf("scp upload must fail, the user cannot create new dirs") + t.Errorf("scp upload must fail, the user cannot create files in a missing dir") } err = scpUpload(testBaseDirPath, remoteUpPath, true, false) if err == nil { @@ -2578,7 +3028,7 @@ func TestSCPPermUpload(t *testing.T) { } usePubKey := true u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermDownload, dataprovider.PermCreateDirs} + u.Permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermCreateDirs} user, _, err := httpd.AddUser(u, http.StatusOK) if err != nil { t.Errorf("unable to add user: %v", err) @@ -2615,7 +3065,7 @@ func TestSCPPermOverwrite(t *testing.T) { } usePubKey := true u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermUpload, dataprovider.PermCreateDirs} + u.Permissions["/"] = []string{dataprovider.PermUpload, dataprovider.PermCreateDirs} user, _, err := httpd.AddUser(u, http.StatusOK) if err != nil { t.Errorf("unable to add user: %v", err) @@ -2656,7 +3106,7 @@ func TestSCPPermDownload(t *testing.T) { } usePubKey := true u := getTestUser(usePubKey) - u.Permissions = []string{dataprovider.PermUpload, dataprovider.PermCreateDirs} + u.Permissions["/"] = []string{dataprovider.PermUpload, dataprovider.PermCreateDirs} user, _, err := httpd.AddUser(u, http.StatusOK) if err != nil { t.Errorf("unable to add user: %v", err) @@ -2668,12 +3118,12 @@ func TestSCPPermDownload(t *testing.T) { if err != nil { t.Errorf("unable to create test file: %v", err) } - remoteUpPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, "tmp") + remoteUpPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, "/") err = scpUpload(testFilePath, remoteUpPath, true, false) if err != nil { t.Errorf("error uploading existing file via scp: %v", err) } - remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/tmp", testFileName)) + remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", testFileName)) localPath := filepath.Join(homeBasePath, "scp_download.dat") err = scpDownload(localPath, remoteDownPath, false, false) if err == nil { @@ -3008,10 +3458,11 @@ func getTestUser(usePubKey bool) dataprovider.User { Username: defaultUsername, Password: defaultPassword, HomeDir: filepath.Join(homeBasePath, defaultUsername), - Permissions: allPerms, Status: 1, ExpirationDate: 0, } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = allPerms if usePubKey { user.PublicKeys = []string{testPubKey} user.Password = "" @@ -3134,10 +3585,10 @@ func sftpUploadFile(localSourcePath string, remoteDestPath string, expectedSize return err } // we need to close the file to trigger the close method on server - // we cannot defer closing or Lstat will fail for upload atomic mode + // we cannot defer closing or Lstat will fail for uploads in atomic mode destFile.Close() if expectedSize > 0 { - fi, err := client.Lstat(remoteDestPath) + fi, err := client.Stat(remoteDestPath) if err != nil { return err } diff --git a/sftpd/ssh_cmd.go b/sftpd/ssh_cmd.go index f7df8746..c5bf73a5 100644 --- a/sftpd/ssh_cmd.go +++ b/sftpd/ssh_cmd.go @@ -129,6 +129,9 @@ func (c *sshCommand) handleHashCommands() error { if err != nil { return c.sendErrorResponse(err) } + if !c.connection.User.HasPerm(dataprovider.PermListItems, path) { + return c.sendErrorResponse(errPermissionDenied) + } hash, err := computeHashForFile(h, path) if err != nil { return c.sendErrorResponse(err) @@ -146,7 +149,7 @@ func (c *sshCommand) executeSystemCommand(command systemCommand) error { } perms := []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, dataprovider.PermListItems, dataprovider.PermOverwrite, dataprovider.PermDelete, dataprovider.PermRename} - if !c.connection.User.HasPerms(perms) { + if !c.connection.User.HasPerms(perms, command.realPath) { return c.sendErrorResponse(errPermissionDenied) } @@ -277,23 +280,6 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) { } args := make([]string, len(c.args)) copy(args, c.args) - if c.command == "rsync" { - // 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) - if c.connection.User.HasPerm(dataprovider.PermCreateSymlinks) { - if !utils.IsStringInSlice("--safe-links", args) { - args = append([]string{"--safe-links"}, args...) - } - } else { - if !utils.IsStringInSlice("--munge-links", args) { - args = append([]string{"--munge-links"}, args...) - } - } - } var path string if len(c.args) > 0 { var err error @@ -305,6 +291,23 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) { args = args[:len(args)-1] args = append(args, path) } + if c.command == "rsync" { + // 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) + if c.connection.User.HasPerm(dataprovider.PermCreateSymlinks, path) { + if !utils.IsStringInSlice("--safe-links", args) { + args = append([]string{"--safe-links"}, args...) + } + } else { + if !utils.IsStringInSlice("--munge-links", args) { + args = append([]string{"--munge-links"}, args...) + } + } + } c.connection.Log(logger.LevelDebug, logSenderSSH, "new system command %#v, with args: %v path: %v", c.command, args, path) cmd := exec.Command(c.command, args...) uid := c.connection.User.GetUID() diff --git a/templates/user.html b/templates/user.html index d7af01c1..cc850dae 100644 --- a/templates/user.html +++ b/templates/user.html @@ -76,13 +76,28 @@ +