diff --git a/.travis.yml b/.travis.yml index e85bd938..41698ff2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ env: - GO111MODULE=on before_script: - - sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL);' + - sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" TEXT NULL);' install: - go get -v -t ./... diff --git a/README.md b/README.md index 5578d919..d6455e98 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Full featured and highly configurable SFTP server - Per user maximum concurrent sessions. - 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). +- Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address. - 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. - Atomic uploads are configurable. @@ -388,6 +389,8 @@ For each account the following properties can be configured: - `chtimes` changing file or directory access and modification time is allowed - `upload_bandwidth` maximum upload bandwidth as KB/s, 0 means unlimited. - `download_bandwidth` maximum download bandwidth as KB/s, 0 means unlimited. +- `allowed_ip`, List of IP/Mask allowed to login. Any IP address not contained in this list cannot login. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32" +- `denied_ip`, List of IP/Mask not allowed to login. If an IP address is both allowed and denied then login will be denied These properties are stored inside the data provider. If you want to use your existing accounts, you can create a database view. Since a view is read only, you have to disable user management and quota tracking so SFTPGo will never try to write to the view. diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index eb48971a..bd8c42cf 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "hash" + "net" "net/http" "net/url" "os" @@ -386,6 +387,41 @@ func validatePermissions(user *User) error { return nil } +func validatePublicKeys(user *User) error { + if len(user.PublicKeys) == 0 { + user.PublicKeys = []string{} + } + for i, k := range user.PublicKeys { + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k)) + if err != nil { + return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)} + } + } + return nil +} + +func validateFilters(user *User) error { + if len(user.Filters.AllowedIP) == 0 { + user.Filters.AllowedIP = []string{} + } + if len(user.Filters.DeniedIP) == 0 { + user.Filters.DeniedIP = []string{} + } + for _, IPMask := range user.Filters.DeniedIP { + _, _, err := net.ParseCIDR(IPMask) + if err != nil { + return &ValidationError{err: fmt.Sprintf("Could not parse denied IP/Mask %#v : %v", IPMask, err)} + } + } + for _, IPMask := range user.Filters.AllowedIP { + _, _, err := net.ParseCIDR(IPMask) + if err != nil { + return &ValidationError{err: fmt.Sprintf("Could not parse allowed IP/Mask %#v : %v", IPMask, err)} + } + } + return nil +} + func validateUser(user *User) error { buildUserHomeDir(user) if len(user.Username) == 0 || len(user.HomeDir) == 0 { @@ -410,14 +446,11 @@ func validateUser(user *User) error { } user.Password = pwd } - if len(user.PublicKeys) == 0 { - user.PublicKeys = []string{} + if err := validatePublicKeys(user); err != nil { + return err } - for i, k := range user.PublicKeys { - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k)) - if err != nil { - return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)} - } + if err := validateFilters(user); err != nil { + return err } return nil } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index 8c5a55e4..7b39e7d3 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -158,8 +158,12 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error { if err != nil { return err } + filters, err := user.GetFiltersAsJSON() + if err != nil { + return err + } _, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, - user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate) + user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters)) return err } @@ -183,8 +187,13 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error { if err != nil { return err } + filters, err := user.GetFiltersAsJSON() + if err != nil { + return err + } _, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, - user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, user.ID) + user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, + string(filters), user.ID) return err } @@ -262,16 +271,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { var permissions sql.NullString var password sql.NullString var publicKey sql.NullString + var filters sql.NullString var err error if row != nil { err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions, &user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate, - &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters) } else { err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions, &user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate, - &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters) } if err != nil { if err == sql.ErrNoRows { @@ -304,5 +314,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { } } } + if filters.Valid { + var userFilters UserFilters + err = json.Unmarshal([]byte(filters.String), &userFilters) + if err == nil { + user.Filters = userFilters + } + } else { + user.Filters = UserFilters{ + AllowedIP: []string{}, + DeniedIP: []string{}, + } + } return user, err } diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index 88d5398c..a94e40cf 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -3,8 +3,8 @@ package dataprovider import "fmt" const ( - selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions," + - "used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status" + selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," + + "used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters" ) func getSQLPlaceholders() []string { @@ -60,18 +60,19 @@ func getQuotaQuery() string { func getAddUserQuery() string { return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions, - used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date) - VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], + used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters) + VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], - sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13]) + sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], + sqlPlaceholders[14]) } func getUpdateUserQuery() string { return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v, - quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v WHERE id = %v`, config.UsersTable, - sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], - sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], - sqlPlaceholders[12], sqlPlaceholders[13]) + quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v WHERE id = %v`, + config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], + sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], + sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14]) } func getDeleteUserQuery() string { diff --git a/dataprovider/user.go b/dataprovider/user.go index 81dc2fb9..05a6eee5 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -3,11 +3,13 @@ package dataprovider import ( "encoding/json" "fmt" + "net" "path" "path/filepath" "strconv" "strings" + "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" ) @@ -40,6 +42,17 @@ const ( PermChtimes = "chtimes" ) +// UserFilters defines additional restrictions for a user +type UserFilters struct { + // only clients connecting from these IP/Mask are allowed. + // IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291 + // for example "192.0.2.0/24" or "2001:db8::/32" + AllowedIP []string `json:"allowed_ip"` + // clients connecting from these IP/Mask are not allowed. + // Denied rules will be evaluated before allowed ones + DeniedIP []string `json:"denied_ip"` +} + // User defines an SFTP user type User struct { // Database unique identifier @@ -83,6 +96,8 @@ type User struct { DownloadBandwidth int64 `json:"download_bandwidth"` // Last login as unix timestamp in milliseconds LastLogin int64 `json:"last_login"` + // Additional restrictions + Filters UserFilters `json:"filters"` } // GetPermissionsForPath returns the permissions for the given path @@ -144,6 +159,41 @@ func (u *User) HasPerms(permissions []string, path string) bool { return true } +// IsLoginAllowed return 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. +// If an IP is both allowed and denied then login will be denied +func (u *User) IsLoginAllowed(remoteAddr string) bool { + if len(u.Filters.AllowedIP) == 0 && len(u.Filters.DeniedIP) == 0 { + return true + } + remoteIP := net.ParseIP(utils.GetIPFromRemoteAddress(remoteAddr)) + // if remoteIP is invalid we allow login, this should never happen + if remoteIP == nil { + logger.Warn(logSender, "", "login allowed for invalid IP. remote address: %#v", remoteAddr) + return true + } + for _, IPMask := range u.Filters.DeniedIP { + _, IPNet, err := net.ParseCIDR(IPMask) + if err != nil { + return false + } + if IPNet.Contains(remoteIP) { + return false + } + } + for _, IPMask := range u.Filters.AllowedIP { + _, IPNet, err := net.ParseCIDR(IPMask) + if err != nil { + return false + } + if IPNet.Contains(remoteIP) { + return true + } + } + return len(u.Filters.AllowedIP) == 0 +} + // GetPermissionsAsJSON returns the permissions as json byte array func (u *User) GetPermissionsAsJSON() ([]byte, error) { return json.Marshal(u.Permissions) @@ -154,6 +204,11 @@ func (u *User) GetPublicKeysAsJSON() ([]byte, error) { return json.Marshal(u.PublicKeys) } +// GetFiltersAsJSON returns the filters as json byte array +func (u *User) GetFiltersAsJSON() ([]byte, error) { + return json.Marshal(u.Filters) +} + // GetUID returns a validate uid, suitable for use with os.Chown func (u *User) GetUID() int { if u.UID <= 0 || u.UID > 65535 { @@ -274,6 +329,12 @@ func (u *User) GetInfoString() string { if u.GID > 0 { result += fmt.Sprintf("GID: %v ", u.GID) } + if len(u.Filters.DeniedIP) > 0 { + result += fmt.Sprintf("Denied IP/Mask: %v ", len(u.Filters.DeniedIP)) + } + if len(u.Filters.AllowedIP) > 0 { + result += fmt.Sprintf("Allowed IP/Mask: %v ", len(u.Filters.AllowedIP)) + } return result } @@ -286,6 +347,30 @@ func (u *User) GetExpirationDateAsString() string { return "" } +// GetAllowedIPAsString returns the allowed IP as comma separated string +func (u User) GetAllowedIPAsString() string { + result := "" + for _, IPMask := range u.Filters.AllowedIP { + if len(result) > 0 { + result += "," + } + result += IPMask + } + return result +} + +// GetDeniedIPAsString returns the denied IP as comma separated string +func (u User) GetDeniedIPAsString() string { + result := "" + for _, IPMask := range u.Filters.DeniedIP { + if len(result) > 0 { + result += "," + } + result += IPMask + } + return result +} + func (u *User) getACopy() User { pubKeys := make([]string, len(u.PublicKeys)) copy(pubKeys, u.PublicKeys) @@ -295,6 +380,12 @@ func (u *User) getACopy() User { copy(perms, v) permissions[k] = perms } + filters := UserFilters{} + filters.AllowedIP = make([]string, len(u.Filters.AllowedIP)) + copy(filters.AllowedIP, u.Filters.AllowedIP) + filters.DeniedIP = make([]string, len(u.Filters.DeniedIP)) + copy(filters.DeniedIP, u.Filters.DeniedIP) + return User{ ID: u.ID, Username: u.Username, @@ -315,6 +406,7 @@ func (u *User) getACopy() User { Status: u.Status, ExpirationDate: u.ExpirationDate, LastLogin: u.LastLogin, + Filters: filters, } } diff --git a/docker/sftpgo/alpine/README.md b/docker/sftpgo/alpine/README.md index 62a9141f..5591ff83 100644 --- a/docker/sftpgo/alpine/README.md +++ b/docker/sftpgo/alpine/README.md @@ -11,6 +11,7 @@ sudo groupadd -g 1003 sftpgrp && \ sudo -u sftpuser mkdir /home/sftpuser/{conf,data} && \ curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20190828.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \ curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191112.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \ + curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191230.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \ curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sftpgo.json -o /home/sftpuser/conf/sftpgo.json # Get and build SFTPGo image diff --git a/httpd/api_utils.go b/httpd/api_utils.go index e08aab13..71d9c0ce 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -97,7 +97,7 @@ func AddUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User, body, _ = getResponseBody(resp) } if err == nil { - err = checkUser(user, newUser) + err = checkUser(&user, &newUser) } return newUser, body, err } @@ -129,7 +129,7 @@ func UpdateUser(user dataprovider.User, expectedStatusCode int) (dataprovider.Us newUser, body, err = GetUserByID(user.ID, expectedStatusCode) } if err == nil { - err = checkUser(user, newUser) + err = checkUser(&user, &newUser) } return newUser, body, err } @@ -376,7 +376,7 @@ func getResponseBody(resp *http.Response) ([]byte, error) { return ioutil.ReadAll(resp.Body) } -func checkUser(expected dataprovider.User, actual dataprovider.User) error { +func checkUser(expected *dataprovider.User, actual *dataprovider.User) error { if len(actual.Password) > 0 { return errors.New("User password must not be visible") } @@ -389,6 +389,9 @@ func checkUser(expected dataprovider.User, actual dataprovider.User) error { return errors.New("user ID mismatch") } } + if len(expected.Permissions) != len(actual.Permissions) { + return errors.New("Permissions mismatch") + } for dir, perms := range expected.Permissions { if actualPerms, ok := actual.Permissions[dir]; ok { for _, v := range actualPerms { @@ -400,10 +403,34 @@ func checkUser(expected dataprovider.User, actual dataprovider.User) error { return errors.New("Permissions directories mismatch") } } + if err := compareUserFilters(expected, actual); err != nil { + return err + } + return compareEqualsUserFields(expected, actual) } -func compareEqualsUserFields(expected dataprovider.User, actual dataprovider.User) error { +func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) error { + if len(expected.Filters.AllowedIP) != len(actual.Filters.AllowedIP) { + return errors.New("AllowedIP mismatch") + } + if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) { + return errors.New("DeniedIP mismatch") + } + for _, IPMask := range expected.Filters.AllowedIP { + if !utils.IsStringInSlice(IPMask, actual.Filters.AllowedIP) { + return errors.New("AllowedIP contents mismatch") + } + } + for _, IPMask := range expected.Filters.DeniedIP { + if !utils.IsStringInSlice(IPMask, actual.Filters.DeniedIP) { + return errors.New("DeniedIP contents mismatch") + } + } + return nil +} + +func compareEqualsUserFields(expected *dataprovider.User, actual *dataprovider.User) error { if expected.Username != actual.Username { return errors.New("Username mismatch") } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index fab96fd9..72bbfe5f 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -217,13 +217,28 @@ func TestAddUserInvalidPerms(t *testing.T) { u.Permissions["/"] = []string{"invalidPerm"} _, _, err := httpd.AddUser(u, http.StatusBadRequest) if err != nil { - t.Errorf("unexpected error adding user with no perms: %v", err) + t.Errorf("unexpected error adding user with invalid 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) + t.Errorf("unexpected error adding user with no root dir perms: %v", err) + } +} + +func TestAddUserInvalidFilters(t *testing.T) { + u := getTestUser() + u.Filters.AllowedIP = []string{"192.168.1.0/24", "192.168.2.0"} + _, _, err := httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid filters: %v", err) + } + u.Filters.AllowedIP = []string{} + u.Filters.DeniedIP = []string{"192.168.3.0/16", "invalid"} + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid filters: %v", err) } } @@ -270,6 +285,8 @@ func TestUpdateUser(t *testing.T) { user.QuotaFiles = 2 user.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload} user.Permissions["/subdir"] = []string{dataprovider.PermListItems, dataprovider.PermUpload} + 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.UploadBandwidth = 1024 user.DownloadBandwidth = 512 user, _, err = httpd.UpdateUser(user, http.StatusOK) @@ -1010,6 +1027,7 @@ func TestStartQuotaScanMock(t *testing.T) { t.Errorf("Error get active scans: %v", err) break } + time.Sleep(100 * time.Millisecond) } _, err = os.Stat(user.HomeDir) if err != nil && os.IsNotExist(err) { @@ -1018,6 +1036,26 @@ func TestStartQuotaScanMock(t *testing.T) { req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON)) rr = executeRequest(req) checkResponseCode(t, http.StatusCreated, rr.Code) + + req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err = render.DecodeJSON(rr.Body, &scans) + if err != nil { + t.Errorf("Error get active scans: %v", err) + } + for len(scans) > 0 { + req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err = render.DecodeJSON(rr.Body, &scans) + if err != nil { + t.Errorf("Error get active scans: %v", err) + break + } + time.Sleep(100 * time.Millisecond) + } + req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) @@ -1158,7 +1196,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") + 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") @@ -1222,6 +1260,20 @@ func TestWebUserAddMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) form.Set("expiration_date", "") + form.Set("allowed_ip", "invalid,ip") + // test invalid allowed_ip + req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + form.Set("allowed_ip", "") + form.Set("denied_ip", "192.168.1.2") // it should be 192.168.1.2/32 + // test invalid denied_ip + req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + form.Set("denied_ip", "") req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) @@ -1255,6 +1307,13 @@ func TestWebUserAddMock(t *testing.T) { if !utils.IsStringInSlice(testPubKey, newUser.PublicKeys) { t.Errorf("public_keys does not match") } + if val, ok := newUser.Permissions["/subdir"]; ok { + if !utils.IsStringInSlice(dataprovider.PermListItems, val) || !utils.IsStringInSlice(dataprovider.PermDownload, val) { + t.Error("permssions for /subdir does not match") + } + } else { + t.Errorf("user permissions must contains /somedir, actual: %v", newUser.Permissions) + } req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) @@ -1285,9 +1344,11 @@ 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("sub_dirs_permissions", "/otherdir : list ,upload ") form.Set("status", strconv.Itoa(user.Status)) 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 ") req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) @@ -1319,6 +1380,19 @@ func TestWebUserUpdateMock(t *testing.T) { if user.GID != updateUser.GID { t.Errorf("gid does not match") } + if val, ok := updateUser.Permissions["/otherdir"]; ok { + if !utils.IsStringInSlice(dataprovider.PermListItems, val) || !utils.IsStringInSlice(dataprovider.PermUpload, val) { + t.Error("permssions for /otherdir does not match") + } + } else { + t.Errorf("user permissions must contains /otherdir, actual: %v", updateUser.Permissions) + } + if !utils.IsStringInSlice("192.168.1.3/32", updateUser.Filters.AllowedIP) { + t.Errorf("Allowed IP/Mask does not match: %v", updateUser.Filters.AllowedIP) + } + if !utils.IsStringInSlice("10.0.0.2/32", updateUser.Filters.DeniedIP) { + t.Errorf("Denied IP/Mask does not match: %v", updateUser.Filters.DeniedIP) + } req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index c4e96a56..b8e5b798 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -43,8 +43,8 @@ func TestCheckResponse(t *testing.T) { } func TestCheckUser(t *testing.T) { - expected := dataprovider.User{} - actual := dataprovider.User{} + expected := &dataprovider.User{} + actual := &dataprovider.User{} actual.Password = "password" err := checkUser(expected, actual) if err == nil { @@ -72,6 +72,10 @@ func TestCheckUser(t *testing.T) { expected.Permissions = make(map[string][]string) expected.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload} actual.Permissions = make(map[string][]string) + err = checkUser(expected, actual) + if err == nil { + t.Errorf("Permissions are not equal") + } actual.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks} err = checkUser(expected, actual) if err == nil { @@ -90,11 +94,37 @@ func TestCheckUser(t *testing.T) { if err == nil { t.Errorf("Permissions are not equal") } + expected.Permissions = make(map[string][]string) + actual.Permissions = make(map[string][]string) + expected.Filters.AllowedIP = []string{} + actual.Filters.AllowedIP = []string{"192.168.1.2/32"} + err = checkUser(expected, actual) + if err == nil { + t.Errorf("AllowedIP are not equal") + } + expected.Filters.AllowedIP = []string{"192.168.1.3/32"} + err = checkUser(expected, actual) + if err == nil { + t.Errorf("AllowedIP contents are not equal") + } + expected.Filters.AllowedIP = []string{} + actual.Filters.AllowedIP = []string{} + expected.Filters.DeniedIP = []string{} + actual.Filters.DeniedIP = []string{"192.168.1.2/32"} + err = checkUser(expected, actual) + if err == nil { + t.Errorf("DeniedIP are not equal") + } + expected.Filters.DeniedIP = []string{"192.168.1.3/32"} + err = checkUser(expected, actual) + if err == nil { + t.Errorf("DeniedIP contents are not equal") + } } func TestCompareUserFields(t *testing.T) { - expected := dataprovider.User{} - actual := dataprovider.User{} + expected := &dataprovider.User{} + actual := &dataprovider.User{} expected.Permissions = make(map[string][]string) actual.Permissions = make(map[string][]string) expected.Username = "test" diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 8d2f32c7..324a19e4 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.4.0 + version: 1.5.0 servers: - url: /api/v1 @@ -608,7 +608,7 @@ paths: - 2 description: > Quota scan: - * `0` no quota scan is done, the imported user will have used_quota_size and used_quota_file = 0 + * `0` no quota scan is done, the imported user will have used_quota_size and used_quota_file = 0 * `1` scan quota * `2` scan quota if the user has quota restrictions required: false @@ -693,6 +693,23 @@ components: 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 + UserFilters: + type: object + properties: + allowed_ip: + type: array + items: + type: string + nullable: true + description: only clients connecting from these IP/Mask are allowed. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32" + example: [ "192.0.2.0/24", "2001:db8::/32" ] + denied_ip: + type: array + items: + type: string + nullable: true + description: clients connecting from these IP/Mask are not allowed. Denied rules are evaluated before allowed ones + example: [ "172.16.0.0/16" ] User: type: object properties: @@ -743,15 +760,15 @@ components: max_sessions: type: integer format: int32 - description: limit the sessions that an user can open. 0 means unlimited + description: Limit the sessions that an user can open. 0 means unlimited quota_size: type: integer format: int64 - description: quota as size in bytes. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed + description: Quota as size in bytes. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed quota_files: type: integer 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 + 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: object items: @@ -767,7 +784,7 @@ components: last_quota_update: type: integer format: int64 - description: last quota update as unix timestamp in milliseconds + description: Last quota update as unix timestamp in milliseconds upload_bandwidth: type: integer format: int32 @@ -779,7 +796,11 @@ components: last_login: type: integer format: int64 - description: last user login as unix timestamp in milliseconds + description: Last user login as unix timestamp in milliseconds + filters: + $ref: '#/components/schemas/UserFilters' + nullable: true + description: Additional restrictions Transfer: type: object properties: diff --git a/httpd/web.go b/httpd/web.go index 4a3be429..c3d7682f 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -184,12 +184,12 @@ 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, ':') { + for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") { + if strings.ContainsRune(cleaned, ':') { dirPerms := strings.Split(cleaned, ":") if len(dirPerms) > 1 { dir := dirPerms[0] + dir = strings.TrimSpace(dir) perms := []string{} for _, p := range strings.Split(dirPerms[1], ",") { cleanedPerm := strings.TrimSpace(p) @@ -206,6 +206,24 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string { return permissions } +func getSliceFromDelimitedValues(values, delimiter string) []string { + result := []string{} + for _, v := range strings.Split(values, delimiter) { + cleaned := strings.TrimSpace(v) + if len(cleaned) > 0 { + result = append(result, cleaned) + } + } + 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"), ",") + return filters +} + func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { var user dataprovider.User err := r.ParseForm() @@ -213,13 +231,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { return user, err } publicKeysFormValue := r.Form.Get("public_keys") - publicKeys := []string{} - for _, v := range strings.Split(publicKeysFormValue, "\n") { - cleaned := strings.TrimSpace(v) - if len(cleaned) > 0 { - publicKeys = append(publicKeys, cleaned) - } - } + publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n") uid, err := strconv.Atoi(r.Form.Get("uid")) if err != nil { return user, err @@ -276,6 +288,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { DownloadBandwidth: bandwidthDL, Status: status, ExpirationDate: expirationDateMillis, + Filters: getFiltersFromUserPostFields(r), } return user, err } diff --git a/scripts/README.md b/scripts/README.md index 4aea3383..c87a614b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -41,7 +41,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 +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" ``` Output: @@ -50,6 +50,12 @@ Output: { "download_bandwidth": 60, "expiration_date": 1546297200000, + "filters": { + "allowed_ip": [ + "192.168.1.1/32" + ], + "denied_ip": [] + }, "gid": 1000, "home_dir": "/tmp/test_home_dir", "id": 9576, @@ -90,7 +96,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 "" +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" ``` Output: @@ -117,6 +123,12 @@ Output: { "download_bandwidth": 80, "expiration_date": 0, + "filters": { + "allowed_ip": [], + "denied_ip": [ + "192.168.1.0/24" + ] + }, "gid": 33, "home_dir": "/tmp/test_home_dir", "id": 9576, @@ -159,6 +171,12 @@ Output: { "download_bandwidth": 80, "expiration_date": 0, + "filters": { + "allowed_ip": [], + "denied_ip": [ + "192.168.1.0/24" + ] + }, "gid": 33, "home_dir": "/tmp/test_home_dir", "id": 9576, diff --git a/scripts/sftpgo_api_cli.py b/scripts/sftpgo_api_cli.py index 0860ecb9..12d135b4 100755 --- a/scripts/sftpgo_api_cli.py +++ b/scripts/sftpgo_api_cli.py @@ -70,9 +70,9 @@ class SFTPGoApiRequests: else: print(r.text) - def buildUserObject(self, user_id=0, username="", password="", public_keys="", home_dir="", uid=0, + 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, - download_bandwidth=0, status=1, expiration_date=0): + download_bandwidth=0, status=1, expiration_date=0, allowed_ip=[], denied_ip=[]): 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, @@ -88,6 +88,8 @@ class SFTPGoApiRequests: user.update({"home_dir":home_dir}) if permissions: user.update({"permissions":permissions}) + if allowed_ip or denied_ip: + user.update({"filters":self.buildFilters(allowed_ip, denied_ip)}) return user def buildPermissions(self, root_perms, subdirs_perms): @@ -107,6 +109,20 @@ class SFTPGoApiRequests: permissions.update({directory:values}) return permissions + def buildFilters(self, allowed_ip, denied_ip): + filters = {} + if allowed_ip: + if len(allowed_ip) == 1 and not allowed_ip[0]: + filters.update({"allowed_ip":[]}) + else: + filters.update({"allowed_ip":allowed_ip}) + if denied_ip: + if len(denied_ip) == 1 and not denied_ip[0]: + filters.update({"denied_ip":[]}) + else: + filters.update({"denied_ip":denied_ip}) + return filters + 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) @@ -118,19 +134,20 @@ class SFTPGoApiRequests: def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0, quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1, - expiration_date=0, subdirs_permissions=[]): + expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[]): 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) + status, expiration_date, allowed_ip, denied_ip) 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, perms=[], upload_bandwidth=0, - download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[]): + download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[], + allowed_ip=[], denied_ip=[]): 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) + status, expiration_date, allowed_ip, denied_ip) r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -251,7 +268,7 @@ class ConvertUsers: user_info = spwd.getspnam(username) password = user_info.sp_pwdp if not password or password == '!!': - print('cannot import user "{}" without password'.format(username)) + print('cannot import user "{}" without a password'.format(username)) continue if user_info.sp_inact > 0: last_pwd_change_diff = days_from_epoch_time - user_info.sp_lstchg @@ -283,11 +300,27 @@ class ConvertUsers: self.addUser(self.SFTPGoRestAPI.buildUserObject(0, username, password, [], home_dir, uid, gid, 0, 0, 0, permissions, 0, 0, 1, 0)) + def convertPureFTPDIP(self, fields): + result = [] + if not fields: + return result + for v in fields.split(","): + ip_mask = v.strip() + if not ip_mask: + continue + if ip_mask.count(".") < 3 and ip_mask.count(":") < 3: + print("cannot import pure-ftpd IP: {}".format(ip_mask)) + continue + if "/" not in ip_mask: + ip_mask += "/32" + result.append(ip_mask) + return result + def convertFromPureFTPD(self): with open(self.input_file, 'r') as f: for line in f: fields = line.split(':') - if len(fields) > 13: + if len(fields) > 16: username = fields[0] password = fields[1] uid = int(fields[2]) @@ -308,6 +341,8 @@ class ConvertUsers: quota_size = 0 if fields[12]: quota_size = int(fields[12]) + allowed_ip = self.convertPureFTPDIP(fields[15]) + denied_ip = self.convertPureFTPDIP(fields[16]) if not self.isUserValid(username, uid, gid): continue if self.force_uid >= 0: @@ -317,7 +352,8 @@ class ConvertUsers: permissions = self.SFTPGoRestAPI.buildPermissions(['*'], []) self.addUser(self.SFTPGoRestAPI.buildUserObject(0, username, password, [], home_dir, uid, gid, max_sessions, quota_size, quota_files, permissions, - upload_bandwidth, download_bandwidth, 1, 0)) + upload_bandwidth, download_bandwidth, 1, 0, allowed_ip, + denied_ip)) def validDate(s): @@ -361,6 +397,10 @@ def addCommonUserArguments(parser): help='User\'s status. 1 enabled, 0 disabled. Default: %(default)s') parser.add_argument('-E', '--expiration-date', type=validDate, default="", help='Expiration date as YYYY-MM-DD, empty string means no expiration. Default: %(default)s') + parser.add_argument('-Y', '--allowed-ip', type=str, nargs='+', default=[], + 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') if __name__ == '__main__': @@ -458,12 +498,13 @@ 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.subdirs_permissions) + args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions, args.allowed_ip, + args.denied_ip) 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.subdirs_permissions) + args.subdirs_permissions, args.allowed_ip, args.denied_ip) elif args.command == 'delete-user': api.deleteUser(args.id) elif args.command == 'get-users': diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index e000be1c..165f3fa4 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -174,7 +174,7 @@ func TestUploadFiles(t *testing.T) { func TestWithInvalidHome(t *testing.T) { u := dataprovider.User{} u.HomeDir = "home_rel_path" - _, err := loginUser(u, "password") + _, err := loginUser(u, "password", "") if err == nil { t.Errorf("login a user with an invalid home_dir must fail") } diff --git a/sftpd/server.go b/sftpd/server.go index 6770c886..272b1c63 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -352,12 +352,24 @@ func (c Configuration) createHandler(connection Connection) sftp.Handlers { } } -func loginUser(user dataprovider.User, loginType string) (*ssh.Permissions, error) { +func loginUser(user dataprovider.User, loginType string, remoteAddr string) (*ssh.Permissions, error) { if !filepath.IsAbs(user.HomeDir) { logger.Warn(logSender, "", "user %#v has an invalid home dir: %#v. Home dir must be an absolute path, login not allowed", user.Username, user.HomeDir) return nil, fmt.Errorf("cannot login user with invalid home dir: %#v", user.HomeDir) } + if user.MaxSessions > 0 { + activeSessions := getActiveSessions(user.Username) + if activeSessions >= user.MaxSessions { + logger.Debug(logSender, "", "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username, + activeSessions, user.MaxSessions) + return nil, fmt.Errorf("too many open sessions: %v", activeSessions) + } + } + if !user.IsLoginAllowed(remoteAddr) { + logger.Debug(logSender, "", "cannot login user %#v, remote address is not allowed: %v", user.Username, remoteAddr) + return nil, fmt.Errorf("Login is not allowed from this address: %v", remoteAddr) + } if _, err := os.Stat(user.HomeDir); os.IsNotExist(err) { err := os.MkdirAll(user.HomeDir, 0777) logger.Debug(logSender, "", "home directory %#v for user %#v does not exist, try to create, mkdir error: %v", @@ -367,15 +379,6 @@ func loginUser(user dataprovider.User, loginType string) (*ssh.Permissions, erro } } - if user.MaxSessions > 0 { - activeSessions := getActiveSessions(user.Username) - if activeSessions >= user.MaxSessions { - logger.Debug(logSender, "", "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username, - activeSessions, user.MaxSessions) - return nil, fmt.Errorf("too many open sessions: %v", activeSessions) - } - } - json, err := json.Marshal(user) if err != nil { logger.Warn(logSender, "", "error serializing user info: %v, authentication rejected", err) @@ -432,7 +435,7 @@ func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKe metrics.AddLoginAttempt(true) if user, keyID, err = dataprovider.CheckUserAndPubKey(dataProvider, conn.User(), pubKey); err == nil { - sshPerm, err = loginUser(user, "public_key:"+keyID) + sshPerm, err = loginUser(user, "public_key:"+keyID, conn.RemoteAddr().String()) } else { logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "public_key", err.Error()) } @@ -447,7 +450,7 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [ metrics.AddLoginAttempt(false) if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil { - sshPerm, err = loginUser(user, "password") + sshPerm, err = loginUser(user, "password", conn.RemoteAddr().String()) } else { logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "password", err.Error()) } diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 92c36fa0..20555ad5 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -1014,6 +1014,60 @@ func TestLoginUserExpiration(t *testing.T) { os.RemoveAll(user.GetHomeDir()) } +func TestLoginWithIPFilters(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.Filters.DeniedIP = []string{"192.167.0.0/24", "172.18.0.0/16"} + u.Filters.AllowedIP = []string{} + 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.Getwd() + if err != nil { + t.Errorf("sftp client with valid credentials must work") + } + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + if err != nil { + t.Errorf("error getting user: %v", err) + } + if user.LastLogin <= 0 { + t.Errorf("last login must be updated after a successful login: %v", user.LastLogin) + } + } + user.Filters.AllowedIP = []string{"127.0.0.0/8"} + _, _, 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("login from an allowed IP must succeed: %v", err) + } else { + defer client.Close() + } + user.Filters.AllowedIP = []string{"172.19.0.0/16"} + _, _, 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("login from an not allowed IP must fail") + client.Close() + } + _, err = httpd.RemoveUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to remove user: %v", err) + } + os.RemoveAll(user.GetHomeDir()) +} + func TestLoginAfterUserUpdateEmptyPwd(t *testing.T) { usePubKey := false user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK) @@ -2581,6 +2635,60 @@ func TestUserPerms(t *testing.T) { } } +func TestUserFiltersIPMaskConditions(t *testing.T) { + user := getTestUser(true) + // with no filter login must be allowed even if the remoteIP is invalid + if !user.IsLoginAllowed("192.168.1.5") { + t.Error("unexpected login denied") + } + if !user.IsLoginAllowed("invalid") { + t.Error("unexpected login denied") + } + user.Filters.DeniedIP = append(user.Filters.DeniedIP, "192.168.1.0/24") + if user.IsLoginAllowed("192.168.1.5") { + t.Error("unexpected login allowed") + } + if !user.IsLoginAllowed("192.168.2.6") { + t.Error("unexpected login denied") + } + user.Filters.AllowedIP = append(user.Filters.AllowedIP, "192.168.1.5/32") + // if the same ip/mask is both denied and allowed then login must be denied + if user.IsLoginAllowed("192.168.1.5") { + t.Error("unexpected login allowed") + } + if user.IsLoginAllowed("192.168.3.6") { + t.Error("unexpected login allowed") + } + user.Filters.DeniedIP = []string{} + if !user.IsLoginAllowed("192.168.1.5") { + t.Error("unexpected login denied") + } + if user.IsLoginAllowed("192.168.1.6") { + t.Error("unexpected login allowed") + } + user.Filters.DeniedIP = []string{"192.168.0.0/16", "172.16.0.0/16"} + user.Filters.AllowedIP = []string{} + if user.IsLoginAllowed("192.168.5.255") { + t.Error("unexpected login allowed") + } + if user.IsLoginAllowed("172.16.1.2") { + t.Error("unexpected login allowed") + } + if !user.IsLoginAllowed("172.18.2.1") { + t.Error("unexpected login denied") + } + user.Filters.AllowedIP = []string{"10.4.4.0/24"} + if user.IsLoginAllowed("10.5.4.2") { + t.Error("unexpected login allowed") + } + if !user.IsLoginAllowed("10.4.4.2") { + t.Error("unexpected login denied") + } + if !user.IsLoginAllowed("invalid") { + t.Error("unexpected login denied") + } +} + func TestSSHCommands(t *testing.T) { usePubKey := false user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK) @@ -2739,8 +2847,6 @@ func TestBasicGitCommands(t *testing.T) { if err != nil { t.Errorf("unexpected error: %v out: %v", err, string(out)) printLatestLogs(10) - out, err = pushToGitRepo(clonePath) - logger.DebugToConsole("new push out: %v, err: %v", string(out), err) } err = waitQuotaScans() if err != nil { diff --git a/sftpd/ssh_cmd.go b/sftpd/ssh_cmd.go index eddba2c0..c9714a14 100644 --- a/sftpd/ssh_cmd.go +++ b/sftpd/ssh_cmd.go @@ -261,7 +261,7 @@ func (c *sshCommand) executeSystemCommand(command systemCommand) error { w, e := transfer.copyFromReaderToWriter(c.connection.channel.Stderr(), stderr, 0) c.connection.Log(logger.LevelDebug, logSenderSSH, "command: %#v, copy from sdterr to remote command ended, written: %v err: %v", c.connection.command, w, e) - // os.ErrClosed means that the command is finished so we don't need to to nothing + // os.ErrClosed means that the command is finished so we don't need to do anything if (e != nil && !errors.Is(e, os.ErrClosed)) || w > 0 { once.Do(closeCmdOnError) } diff --git a/sql/mysql/20191230.sql b/sql/mysql/20191230.sql new file mode 100644 index 00000000..df77ea42 --- /dev/null +++ b/sql/mysql/20191230.sql @@ -0,0 +1,6 @@ +BEGIN; +-- +-- Add field filters to user +-- +ALTER TABLE `users` ADD COLUMN `filters` longtext NULL; +COMMIT; \ No newline at end of file diff --git a/sql/pgsql/20191230.sql b/sql/pgsql/20191230.sql new file mode 100644 index 00000000..a33469fb --- /dev/null +++ b/sql/pgsql/20191230.sql @@ -0,0 +1,6 @@ +BEGIN; +-- +-- Add field filters to user +-- +ALTER TABLE "users" ADD COLUMN "filters" text NULL; +COMMIT; \ No newline at end of file diff --git a/sql/sqlite/20191230.sql b/sql/sqlite/20191230.sql new file mode 100644 index 00000000..51afd3b9 --- /dev/null +++ b/sql/sqlite/20191230.sql @@ -0,0 +1,9 @@ +BEGIN; +-- +-- Add field filters to user +-- +CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "filters" text NULL, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL); +INSERT INTO "new__users" ("id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", "filters") SELECT "id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", NULL FROM "users"; +DROP TABLE "users"; +ALTER TABLE "new__users" RENAME TO "users"; +COMMIT; \ No newline at end of file diff --git a/templates/user.html b/templates/user.html index cc850dae..560c58e1 100644 --- a/templates/user.html +++ b/templates/user.html @@ -169,6 +169,28 @@ +
+ +
+ + + Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32" + +
+
+ +
+ +
+ + + Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32" + +
+
+ @@ -189,27 +211,27 @@ } }); - {{ if gt .User.ExpirationDate 0 }} + { { if gt.User.ExpirationDate 0 } } var input_dt = moment({{.User.ExpirationDate }}).format('YYYY-MM-DD'); - $('#idExpirationDate').val(input_dt); - $('#expirationDatePicker').datetimepicker('viewDate', input_dt); - {{end}} + $('#idExpirationDate').val(input_dt); + $('#expirationDatePicker').datetimepicker('viewDate', input_dt); + { { end } } - $("#user_form").submit(function( event ) { - var dt = $('#idExpirationDate').val(); - if (dt){ - var d = $('#expirationDatePicker').datetimepicker('viewDate'); - if (d){ - var dateString = moment(d).format('YYYY-MM-DD HH:mm:ss'); - $('#hidden_start_datetime').val(dateString); - } else { - $('#hidden_start_datetime').val(""); - } + $("#user_form").submit(function (event) { + var dt = $('#idExpirationDate').val(); + if (dt) { + var d = $('#expirationDatePicker').datetimepicker('viewDate'); + if (d) { + var dateString = moment(d).format('YYYY-MM-DD HH:mm:ss'); + $('#hidden_start_datetime').val(dateString); } else { $('#hidden_start_datetime').val(""); } - return true; - }); + } else { + $('#hidden_start_datetime').val(""); + } + return true; + }); }); {{end}} \ No newline at end of file