Sfoglia il codice sorgente

add support for a start directory

Fixes #705

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 anni fa
parent
commit
5c2fd8d52a

+ 10 - 10
.github/workflows/development.yml

@@ -20,12 +20,12 @@ jobs:
             upload-coverage: false
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
 
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ matrix.go }}
 
@@ -218,10 +218,10 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: 1.17
 
@@ -274,10 +274,10 @@ jobs:
           - 3307:3306
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: 1.17
 
@@ -345,12 +345,12 @@ jobs:
             go: latest
             go-arch: arm7
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
       - name: Set up Go
         if: ${{ matrix.arch == 'amd64' }}
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ matrix.go }}
 
@@ -449,10 +449,10 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: 1.17
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Run golangci-lint
         uses: golangci/golangci-lint-action@v3
         with:

+ 1 - 1
.github/workflows/docker.yml

@@ -30,7 +30,7 @@ jobs:
             optional_deps: false
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Gather image information
         id: info

+ 8 - 8
.github/workflows/release.yml

@@ -5,16 +5,16 @@ on:
     tags: 'v*'
 
 env:
-  GO_VERSION: 1.17.5
+  GO_VERSION: 1.17.7
 
 jobs:
   prepare-sources-with-deps:
     name: Prepare sources with deps
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ env.GO_VERSION }}
 
@@ -45,9 +45,9 @@ jobs:
         os: [macos-10.15, windows-2019]
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Set up Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ env.GO_VERSION }}
 
@@ -283,10 +283,10 @@ jobs:
             tar-arch: armv7
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Set up Go
         if: ${{ matrix.arch == 'amd64' }}
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ env.GO_VERSION }}
 
@@ -467,7 +467,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: Get versions
         id: get_version
         run: |

+ 6 - 1
cmd/portable.go

@@ -29,6 +29,7 @@ var (
 	portableAdvertiseCredentials       bool
 	portableUsername                   string
 	portablePassword                   string
+	portableStartDir                   string
 	portableLogFile                    string
 	portableLogVerbose                 bool
 	portableLogUTCTime                 bool
@@ -163,7 +164,8 @@ Please take a look at the usage below to customize the serving parameters`,
 					},
 					Filters: dataprovider.UserFilters{
 						BaseUserFilters: sdk.BaseUserFilters{
-							FilePatterns: parsePatternsFilesFilters(),
+							FilePatterns:   parsePatternsFilesFilters(),
+							StartDirectory: portableStartDir,
 						},
 					},
 					FsConfig: vfs.Filesystem{
@@ -246,6 +248,9 @@ func init() {
 This can be an absolute path or a path
 relative to the current directory
 `)
+	portableCmd.Flags().StringVar(&portableStartDir, "start-directory", "/", `Alternate start directory.
+This is a virtual path not a filesystem
+path`)
 	portableCmd.Flags().IntVarP(&portableSFTPDPort, "sftpd-port", "s", 0, `0 means a random unprivileged port,
 < 0 disabled`)
 	portableCmd.Flags().IntVar(&portableFTPDPort, "ftpd-port", -1, `0 means a random unprivileged port,

+ 1 - 0
common/common_test.go

@@ -884,6 +884,7 @@ func TestGetTLSVersion(t *testing.T) {
 func TestCleanPath(t *testing.T) {
 	assert.Equal(t, "/", util.CleanPath("/"))
 	assert.Equal(t, "/", util.CleanPath("."))
+	assert.Equal(t, "/", util.CleanPath(""))
 	assert.Equal(t, "/", util.CleanPath("/."))
 	assert.Equal(t, "/", util.CleanPath("/a/.."))
 	assert.Equal(t, "/a", util.CleanPath("/a/"))

+ 13 - 3
dataprovider/dataprovider.go

@@ -2021,6 +2021,18 @@ func validateTransferLimitsFilter(user *User) error {
 	return nil
 }
 
+func updateFiltersValues(user *User) {
+	if !user.HasExternalAuth() {
+		user.Filters.ExternalAuthCacheTime = 0
+	}
+	if user.Filters.StartDirectory != "" {
+		user.Filters.StartDirectory = util.CleanPath(user.Filters.StartDirectory)
+		if user.Filters.StartDirectory == "/" {
+			user.Filters.StartDirectory = ""
+		}
+	}
+}
+
 func validateFilters(user *User) error {
 	checkEmptyFiltersStruct(user)
 	if err := validateIPFilters(user); err != nil {
@@ -2061,9 +2073,7 @@ func validateFilters(user *User) error {
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
 		}
 	}
-	if !user.HasExternalAuth() {
-		user.Filters.ExternalAuthCacheTime = 0
-	}
+	updateFiltersValues(user)
 
 	return validateFiltersPatternExtensions(user)
 }

+ 46 - 1
dataprovider/user.go

@@ -219,6 +219,13 @@ func (u *User) CheckFsRoot(connectionID string) error {
 		return err
 	}
 	fs.CheckRootPath(u.Username, u.GetUID(), u.GetGID())
+	if u.Filters.StartDirectory != "" {
+		err = u.checkDirWithParents(u.Filters.StartDirectory, connectionID)
+		if err != nil {
+			logger.Warn(logSender, connectionID, "could not create start directory %#v, err: %v",
+				u.Filters.StartDirectory, err)
+		}
+	}
 	for idx := range u.VirtualFolders {
 		v := &u.VirtualFolders[idx]
 		fs, err = u.GetFilesystemForPath(v.VirtualPath, connectionID)
@@ -234,6 +241,23 @@ func (u *User) CheckFsRoot(connectionID string) error {
 	return nil
 }
 
+// GetCleanedPath returns a clean POSIX absolute path using the user start directory as base
+// if the provided rawVirtualPath is relative
+func (u *User) GetCleanedPath(rawVirtualPath string) string {
+	if u.Filters.StartDirectory != "" {
+		if !path.IsAbs(rawVirtualPath) {
+			var b strings.Builder
+
+			b.Grow(len(u.Filters.StartDirectory) + 1 + len(rawVirtualPath))
+			b.WriteString(u.Filters.StartDirectory)
+			b.WriteString("/")
+			b.WriteString(rawVirtualPath)
+			return util.CleanPath(b.String())
+		}
+	}
+	return util.CleanPath(rawVirtualPath)
+}
+
 // isFsEqual returns true if the fs has the same configuration
 func (u *User) isFsEqual(other *User) bool {
 	if u.FsConfig.Provider == sdk.LocalFilesystemProvider && u.GetHomeDir() != other.GetHomeDir() {
@@ -242,6 +266,9 @@ func (u *User) isFsEqual(other *User) bool {
 	if !u.FsConfig.IsEqual(&other.FsConfig) {
 		return false
 	}
+	if u.Filters.StartDirectory != other.Filters.StartDirectory {
+		return false
+	}
 	if len(u.VirtualFolders) != len(other.VirtualFolders) {
 		return false
 	}
@@ -586,13 +613,30 @@ func (u *User) GetVirtualFoldersInPath(virtualPath string) map[string]bool {
 		}
 	}
 
+	if u.Filters.StartDirectory != "" {
+		dirsForPath := util.GetDirsForVirtualPath(u.Filters.StartDirectory)
+		for index := range dirsForPath {
+			d := dirsForPath[index]
+			if d == "/" {
+				continue
+			}
+			if path.Dir(d) == virtualPath {
+				result[d] = true
+			}
+		}
+	}
+
 	return result
 }
 
+func (u *User) hasVirtualDirs() bool {
+	return len(u.VirtualFolders) > 0 || u.Filters.StartDirectory != ""
+}
+
 // FilterListDir adds virtual folders and remove hidden items from the given files list
 func (u *User) FilterListDir(dirContents []os.FileInfo, virtualPath string) []os.FileInfo {
 	filter := u.getPatternsFilterForPath(virtualPath)
-	if len(u.VirtualFolders) == 0 && filter.DenyPolicy != sdk.DenyPolicyHide {
+	if !u.hasVirtualDirs() && filter.DenyPolicy != sdk.DenyPolicyHide {
 		return dirContents
 	}
 
@@ -1395,6 +1439,7 @@ func (u *User) getACopy() User {
 	filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
 	filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled
 	filters.DisableFsChecks = u.Filters.DisableFsChecks
+	filters.StartDirectory = u.Filters.StartDirectory
 	filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
 	filters.ExternalAuthCacheTime = u.Filters.ExternalAuthCacheTime
 	filters.WebClient = make([]string, len(u.Filters.WebClient))

+ 1 - 1
docs/defender.md

@@ -2,7 +2,7 @@
 
 The built-in `defender` allows you to configure an auto-blocking policy for SFTPGo and thus helps to prevent DoS (Denial of Service) and brute force password guessing.
 
-If enabled it will protect SFTP, FTP and WebDAV services and it will automatically block hosts (IP addresses) that continually fail to log in or attempt to connect.
+If enabled it will protect SFTP, HTTP, FTP and WebDAV services and it will automatically block hosts (IP addresses) that continually fail to log in or attempt to connect.
 
 You can configure a score for the following events:
 

+ 3 - 0
docs/portable-mode.md

@@ -126,6 +126,9 @@ Flags:
                                         "*" means any supported SSH command
                                         including scp
                                          (default [md5sum,sha1sum,cd,pwd,scp])
+      --start-directory string          Alternate start directory.
+                                        This is a virtual path not a filesystem
+                                        path (default "/")
   -u, --username string                 Leave empty to use an auto generated
                                         value
       --webdav-cert string              Path to the certificate file for WebDAV

+ 62 - 0
ftpd/ftpd_test.go

@@ -597,6 +597,68 @@ func TestBasicFTPHandling(t *testing.T) {
 		50*time.Millisecond)
 }
 
+func TestStartDirectory(t *testing.T) {
+	startDir := "/start/dir"
+	u := getTestUser()
+	u.Filters.StartDirectory = startDir
+	localUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	u = getTestSFTPUser()
+	u.Filters.StartDirectory = startDir
+	sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	for _, user := range []dataprovider.User{localUser, sftpUser} {
+		client, err := getFTPClient(user, true, nil)
+		if assert.NoError(t, err) {
+			currentDir, err := client.CurrentDir()
+			assert.NoError(t, err)
+			assert.Equal(t, startDir, currentDir)
+
+			testFilePath := filepath.Join(homeBasePath, testFileName)
+			testFileSize := int64(65535)
+			err = createTestFile(testFilePath, testFileSize)
+			assert.NoError(t, err)
+			err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
+			assert.NoError(t, err)
+			localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+			err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
+			assert.NoError(t, err)
+			entries, err := client.List(".")
+			assert.NoError(t, err)
+			assert.Len(t, entries, 3)
+
+			entries, err = client.List("/")
+			assert.NoError(t, err)
+			assert.Len(t, entries, 2)
+
+			err = client.ChangeDirToParent()
+			assert.NoError(t, err)
+			currentDir, err = client.CurrentDir()
+			assert.NoError(t, err)
+			assert.Equal(t, path.Dir(startDir), currentDir)
+			err = client.ChangeDirToParent()
+			assert.NoError(t, err)
+			currentDir, err = client.CurrentDir()
+			assert.NoError(t, err)
+			assert.Equal(t, "/", currentDir)
+
+			err = os.Remove(testFilePath)
+			assert.NoError(t, err)
+			err = os.Remove(localDownloadPath)
+			assert.NoError(t, err)
+			err = client.Quit()
+			assert.NoError(t, err)
+		}
+	}
+
+	_, err = httpdtest.RemoveUser(sftpUser, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(localUser, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(localUser.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestMultiFactorAuth(t *testing.T) {
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)

+ 2 - 0
ftpd/internal_test.go

@@ -262,6 +262,8 @@ func (cc mockFTPClientContext) Path() string {
 	return ""
 }
 
+func (cc mockFTPClientContext) SetPath(name string) {}
+
 func (cc mockFTPClientContext) SetDebug(debug bool) {}
 
 func (cc mockFTPClientContext) Debug() bool {

+ 9 - 0
ftpd/server.go

@@ -201,6 +201,7 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
 	if err != nil {
 		return nil, err
 	}
+	setStartDirectory(user.Filters.StartDirectory, cc)
 	connection.Log(logger.LevelInfo, "User %#v logged in with %#v from ip %#v", user.Username, loginMethod, ipAddr)
 	dataprovider.UpdateLastLogin(&user)
 	return connection, nil
@@ -246,6 +247,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo
 					if err != nil {
 						return nil, err
 					}
+					setStartDirectory(dbUser.Filters.StartDirectory, cc)
 					connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP using a TLS certificate, username: %#v, home_dir: %#v remote addr: %#v",
 						dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr)
 					dataprovider.UpdateLastLogin(&dbUser)
@@ -367,6 +369,13 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 	return connection, nil
 }
 
+func setStartDirectory(startDirectory string, cc ftpserver.ClientContext) {
+	if startDirectory == "" {
+		return
+	}
+	cc.SetPath(startDirectory)
+}
+
 func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
 	metric.AddLoginAttempt(loginMethod)
 	if err != nil && err != common.ErrInternalFailure {

+ 9 - 9
go.mod

@@ -8,11 +8,11 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
-	github.com/aws/aws-sdk-go v1.43.7
+	github.com/aws/aws-sdk-go v1.43.10
 	github.com/cockroachdb/cockroach-go/v2 v2.2.8
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
-	github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f
+	github.com/fclairamb/ftpserverlib v0.17.1-0.20220302132530-f366fc1586cb
 	github.com/fclairamb/go-log v0.2.0
 	github.com/go-chi/chi/v5 v5.0.8-0.20220103230436-7dbe9a0bd10f
 	github.com/go-chi/jwtauth/v5 v5.0.2
@@ -27,22 +27,22 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.0
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/klauspost/compress v1.14.4
-	github.com/lestrrat-go/jwx v1.2.19
+	github.com/lestrrat-go/jwx v1.2.20
 	github.com/lib/pq v1.10.4
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/mattn/go-sqlite3 v1.14.12
 	github.com/mhale/smtpd v0.8.0
 	github.com/minio/sio v0.3.0
 	github.com/otiai10/copy v1.7.0
-	github.com/pires/go-proxyproto v0.6.1
-	github.com/pkg/sftp v1.13.5-0.20220119192800-7d25d533c9a3
+	github.com/pires/go-proxyproto v0.6.2
+	github.com/pkg/sftp v1.13.5-0.20220303113417-dcfc1d5e4162
 	github.com/pquerna/otp v1.3.0
 	github.com/prometheus/client_golang v1.12.1
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.3.0
 	github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672
-	github.com/sftpgo/sdk v0.1.1-0.20220228183957-d7251ba29961
-	github.com/shirou/gopsutil/v3 v3.22.1
+	github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712
+	github.com/shirou/gopsutil/v3 v3.22.2
 	github.com/spf13/afero v1.8.1
 	github.com/spf13/cobra v1.3.0
 	github.com/spf13/viper v1.10.1
@@ -67,7 +67,7 @@ require (
 require (
 	cloud.google.com/go v0.100.2 // indirect
 	cloud.google.com/go/compute v1.5.0 // indirect
-	cloud.google.com/go/iam v0.2.0 // indirect
+	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
@@ -130,7 +130,7 @@ require (
 	golang.org/x/tools v0.1.9 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220228155957-1da8797a5878 // indirect
+	google.golang.org/genproto v0.0.0-20220302033224-9aa15565e42a // indirect
 	google.golang.org/grpc v1.44.0 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
 	gopkg.in/ini.v1 v1.66.4 // indirect

+ 18 - 18
go.sum

@@ -55,8 +55,8 @@ cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1
 cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
 cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
-cloud.google.com/go/iam v0.2.0 h1:Ouq6qif4mZdXkb3SiFMpxvu0JQJB1Yid9TsZ23N6hg8=
-cloud.google.com/go/iam v0.2.0/go.mod h1:BCK88+tmjAwnZYfOSizmKCTSFjJHCa18t3DpdGEY13Y=
+cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
+cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
 cloud.google.com/go/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0=
 cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
 cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE=
@@ -144,8 +144,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
 github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.43.7 h1:Gbs53KxXJWbO3txoVkevf56bhdDFqRisl7MQQ6581vc=
-github.com/aws/aws-sdk-go v1.43.7/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go v1.43.10 h1:lFX6gzTBltYBnlJBjd2DWRCmqn2CbTcs6PW99/Dme7k=
+github.com/aws/aws-sdk-go v1.43.10/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
 github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
@@ -245,8 +245,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f h1:75ugogj/lKTVyDHTm0c5zgA16Fpfo/xiNpo8D/zn+TA=
-github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f/go.mod h1:1y0ShfZWIRcgU0mVJaCjEYIu2+g37cRHgDIT8jemeO0=
+github.com/fclairamb/ftpserverlib v0.17.1-0.20220302132530-f366fc1586cb h1:2gBRfMEhjADP8KN88nmq3Py8+vsXhdXyocfETy8gmaI=
+github.com/fclairamb/ftpserverlib v0.17.1-0.20220302132530-f366fc1586cb/go.mod h1:RpiJGed4zOypZ2uy2xnujfTQvveToG6VQRhap7ke4x4=
 github.com/fclairamb/go-log v0.2.0 h1:HzeOyomBVd0tEVLdIK0bBZr0j3xNip+zE1OqC1i5kbM=
 github.com/fclairamb/go-log v0.2.0/go.mod h1:sd5oPNsxdVKRgWI8fVke99GXONszE3bsni2JxQMz8RU=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
@@ -546,8 +546,8 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++
 github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
-github.com/lestrrat-go/jwx v1.2.19 h1:qxxLmAXNwZpTTvjc4PH21nT7I4wPK6lVv3lVNcZPnUk=
-github.com/lestrrat-go/jwx v1.2.19/go.mod h1:bWTBO7IHHVMtNunM8so9MT8wD+euEY1PzGEyCnuI2qM=
+github.com/lestrrat-go/jwx v1.2.20 h1:ckMNlG0MqCcVp7LnD5FN2+459ndm7SW3vryE79Dz9nk=
+github.com/lestrrat-go/jwx v1.2.20/go.mod h1:tLE1XszaFgd7zaS5wHe4NxA+XVhu7xgdRvDpNyi3kNM=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -636,16 +636,16 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
 github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
 github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pires/go-proxyproto v0.6.1 h1:EBupykFmo22SDjv4fQVQd2J9NOoLPmyZA/15ldOGkPw=
-github.com/pires/go-proxyproto v0.6.1/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY=
+github.com/pires/go-proxyproto v0.6.2 h1:KAZ7UteSOt6urjme6ZldyFm4wDe/z0ZUP0Yv0Dos0d8=
+github.com/pires/go-proxyproto v0.6.2/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
-github.com/pkg/sftp v1.13.5-0.20220119192800-7d25d533c9a3 h1:gyvzmVdk4vso+w4gt8x2YtMdbAGSyX5KnekiEsbDLvQ=
-github.com/pkg/sftp v1.13.5-0.20220119192800-7d25d533c9a3/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
+github.com/pkg/sftp v1.13.5-0.20220303113417-dcfc1d5e4162 h1:uJSlAAzEUQq5tpfK+SWIIx/3UJ4EpjAYuMqZpKYrmw4=
+github.com/pkg/sftp v1.13.5-0.20220303113417-dcfc1d5e4162/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -700,10 +700,10 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/sftpgo/sdk v0.1.1-0.20220228183957-d7251ba29961 h1:XpSoX58U9KR5qbexs3VUBZvgcRogjgbALWzQO4TIZKo=
-github.com/sftpgo/sdk v0.1.1-0.20220228183957-d7251ba29961/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
-github.com/shirou/gopsutil/v3 v3.22.1 h1:33y31Q8J32+KstqPfscvFwBlNJ6xLaBy4xqBXzlYV5w=
-github.com/shirou/gopsutil/v3 v3.22.1/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
+github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712 h1:+Rgx0SgsDnFSI5JBwL4mcCH2lkx3yKhLWcQnf0s2JKE=
+github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
+github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks=
+github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -1190,8 +1190,8 @@ google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2
 google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220228155957-1da8797a5878 h1:gERY0VtsF9UyyyCsPSjRk9/RWlcKSa/Gw/aenR/5z48=
-google.golang.org/genproto v0.0.0-20220228155957-1da8797a5878/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220302033224-9aa15565e42a h1:uqouglH745GoGeZ1YFZbPBiu961tgi/9Qm5jaorajjQ=
+google.golang.org/genproto v0.0.0-20220302033224-9aa15565e42a/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

+ 12 - 12
httpd/api_http_user.go

@@ -55,7 +55,7 @@ func readUserFolder(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	contents, err := connection.ReadDir(name)
 	if err != nil {
 		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
@@ -73,7 +73,7 @@ func createUserDir(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	if getBoolQueryParam(r, "mkdir_parents") {
 		if err = connection.CheckParentDirs(path.Dir(name)); err != nil {
 			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
@@ -97,8 +97,8 @@ func renameUserDir(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	oldName := util.CleanPath(r.URL.Query().Get("path"))
-	newName := util.CleanPath(r.URL.Query().Get("target"))
+	oldName := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+	newName := connection.User.GetCleanedPath(r.URL.Query().Get("target"))
 	err = connection.Rename(oldName, newName)
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename directory %#v to %#v", oldName, newName),
@@ -117,7 +117,7 @@ func deleteUserDir(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	err = connection.RemoveDir(name)
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete directory %#v", name), getMappedStatusCode(err))
@@ -135,7 +135,7 @@ func getUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	if name == "/" {
 		sendAPIResponse(w, r, nil, "Please set the path to a valid file", http.StatusBadRequest)
 		return
@@ -186,7 +186,7 @@ func setFileDirMetadata(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	attrs := common.StatAttributes{
 		Flags: common.StatAttrTimes,
 		Atime: util.GetTimeFromMsecSinceEpoch(mTime),
@@ -217,7 +217,7 @@ func uploadUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	filePath := util.CleanPath(r.URL.Query().Get("path"))
+	filePath := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	if getBoolQueryParam(r, "mkdir_parents") {
 		if err = connection.CheckParentDirs(path.Dir(filePath)); err != nil {
 			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
@@ -279,7 +279,7 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
 	connection.RemoveTransfer(t)
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 
-	parentDir := util.CleanPath(r.URL.Query().Get("path"))
+	parentDir := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	files := r.MultipartForm.File["filenames"]
 	if len(files) == 0 {
 		sendAPIResponse(w, r, nil, "No files uploaded!", http.StatusBadRequest)
@@ -339,8 +339,8 @@ func renameUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	oldName := util.CleanPath(r.URL.Query().Get("path"))
-	newName := util.CleanPath(r.URL.Query().Get("target"))
+	oldName := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+	newName := connection.User.GetCleanedPath(r.URL.Query().Get("target"))
 	err = connection.Rename(oldName, newName)
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename file %#v to %#v", oldName, newName),
@@ -359,7 +359,7 @@ func deleteUserFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	fs, p, err := connection.GetFsAndResolvedPath(name)
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %#v", name), getMappedStatusCode(err))

+ 2 - 2
httpd/api_utils.go

@@ -217,7 +217,7 @@ func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir stri
 	wr := zip.NewWriter(w)
 
 	for _, file := range files {
-		fullPath := path.Join(baseDir, file)
+		fullPath := util.CleanPath(path.Join(baseDir, file))
 		if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
 			if share != nil {
 				dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck
@@ -252,7 +252,7 @@ func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string) er
 			return err
 		}
 		for _, info := range contents {
-			fullPath := path.Join(entryPath, info.Name())
+			fullPath := util.CleanPath(path.Join(entryPath, info.Name()))
 			if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
 				return err
 			}

+ 0 - 3
httpd/handler.go

@@ -62,7 +62,6 @@ func (c *Connection) GetCommand() string {
 func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) {
 	c.UpdateLastActivity()
 
-	name = util.CleanPath(name)
 	if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(name)) {
 		return nil, c.GetPermissionDeniedError()
 	}
@@ -78,7 +77,6 @@ func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) {
 func (c *Connection) ReadDir(name string) ([]os.FileInfo, error) {
 	c.UpdateLastActivity()
 
-	name = util.CleanPath(name)
 	return c.ListDir(name)
 }
 
@@ -91,7 +89,6 @@ func (c *Connection) getFileReader(name string, offset int64, method string) (io
 		return nil, c.GetReadQuotaExceededError()
 	}
 
-	name = util.CleanPath(name)
 	if !c.User.HasPerm(dataprovider.PermDownload, path.Dir(name)) {
 		return nil, c.GetPermissionDeniedError()
 	}

+ 142 - 0
httpd/httpd_test.go

@@ -11314,6 +11314,146 @@ func TestWebFilesAPI(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 }
 
+func TestStartDirectory(t *testing.T) {
+	u := getTestUser()
+	u.Filters.StartDirectory = "/start/dir"
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	filename := "file1.txt"
+	body := new(bytes.Buffer)
+	writer := multipart.NewWriter(body)
+	part1, err := writer.CreateFormFile("filenames", filename)
+	assert.NoError(t, err)
+	_, err = part1.Write([]byte("test content"))
+	assert.NoError(t, err)
+	err = writer.Close()
+	assert.NoError(t, err)
+	reader := bytes.NewReader(body.Bytes())
+	req, err := http.NewRequest(http.MethodPost, userFilesPath, reader)
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	setBearerForReq(req, webAPIToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	// check we have 2 files in the defined start dir
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	var contents []map[string]interface{}
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	if assert.Len(t, contents, 1) {
+		assert.Equal(t, filename, contents[0]["name"].(string))
+	}
+	req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=file2.txt",
+		bytes.NewBuffer([]byte("single upload content")))
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path=testdir", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+
+	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path=testdir&target=testdir1", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path=%2Ftestdirroot", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userDirsPath+"?path="+url.QueryEscape(u.Filters.StartDirectory), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 3)
+
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path="+filename, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path=%2F"+filename, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodPatch, userFilesPath+"?path="+filename+"&target="+filename+"_rename", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodDelete, userDirsPath+"?path=testdir1", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 2)
+
+	req, err = http.NewRequest(http.MethodGet, webClientDirsPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 2)
+
+	req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path="+filename+"_rename", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userDirsPath+"?path="+url.QueryEscape(u.Filters.StartDirectory), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = nil
+	err = json.NewDecoder(rr.Body).Decode(&contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 1)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestWebFilesTransferQuotaLimits(t *testing.T) {
 	u := getTestUser()
 	u.UploadDataTransfer = 1
@@ -13947,6 +14087,7 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("disable_fs_checks", "checked")
 	form.Set("total_data_transfer", "0")
 	form.Set("external_auth_cache_time", "0")
+	form.Set("start_directory", "start/dir")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	// test invalid url escape
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
@@ -14228,6 +14369,7 @@ func TestWebUserAddMock(t *testing.T) {
 	assert.True(t, newUser.Filters.DisableFsChecks)
 	assert.False(t, newUser.Filters.AllowAPIKeyAuth)
 	assert.Equal(t, user.Email, newUser.Email)
+	assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
 	assert.True(t, util.IsStringInSlice(testPubKey, newUser.PublicKeys))
 	if val, ok := newUser.Permissions["/subdir"]; ok {
 		assert.True(t, util.IsStringInSlice(dataprovider.PermListItems, val))

+ 1 - 0
httpd/webadmin.go

@@ -948,6 +948,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0
 	filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 	filters.ExternalAuthCacheTime, err = strconv.ParseInt(r.Form.Get("external_auth_cache_time"), 10, 64)
+	filters.StartDirectory = r.Form.Get("start_directory")
 	return filters, err
 }
 

+ 4 - 15
httpd/webclient.go

@@ -598,11 +598,7 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := "/"
-	if _, ok := r.URL.Query()["path"]; ok {
-		name = util.CleanPath(r.URL.Query().Get("path"))
-	}
-
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	files := r.URL.Query().Get("files")
 	var filesList []string
 	err = json.Unmarshal([]byte(files), &filesList)
@@ -742,11 +738,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := "/"
-	if _, ok := r.URL.Query()["path"]; ok {
-		name = util.CleanPath(r.URL.Query().Get("path"))
-	}
-
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	contents, err := connection.ReadDir(name)
 	if err != nil {
 		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
@@ -820,10 +812,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := "/"
-	if _, ok := r.URL.Query()["path"]; ok {
-		name = util.CleanPath(r.URL.Query().Get("path"))
-	}
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	var info os.FileInfo
 	if name == "/" {
 		info = vfs.NewFileInfo(name, true, 0, time.Now(), false)
@@ -880,7 +869,7 @@ func handleClientEditFile(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
-	name := util.CleanPath(r.URL.Query().Get("path"))
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
 	info, err := connection.Stat(name, 0)
 	if err != nil {
 		renderClientMessagePage(w, r, fmt.Sprintf("Unable to stat file %#v", name), "",

+ 7 - 0
httpdtest/httpdtest.go

@@ -1484,6 +1484,10 @@ func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovid
 			return errors.New("web client options contents mismatch")
 		}
 	}
+	return compareUserFiltersEqualFields(expected, actual)
+}
+
+func compareUserFiltersEqualFields(expected *dataprovider.User, actual *dataprovider.User) error {
 	if expected.Filters.Hooks.ExternalAuthDisabled != actual.Filters.Hooks.ExternalAuthDisabled {
 		return errors.New("external_auth_disabled hook mismatch")
 	}
@@ -1496,6 +1500,9 @@ func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovid
 	if expected.Filters.DisableFsChecks != actual.Filters.DisableFsChecks {
 		return errors.New("disable_fs_checks mismatch")
 	}
+	if expected.Filters.StartDirectory != actual.Filters.StartDirectory {
+		return errors.New("start_directory mismatch")
+	}
 	return nil
 }
 

+ 6 - 3
openapi/openapi.yaml

@@ -211,7 +211,7 @@ paths:
       parameters:
         - in: query
           name: path
-          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the user's start directory is assumed. If relative, the user's start directory is used as the base
           schema:
             type: string
       responses:
@@ -3632,7 +3632,7 @@ paths:
       parameters:
         - in: query
           name: path
-          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the user's start directory is assumed. If relative, the user's start directory is used as the base
           schema:
             type: string
       responses:
@@ -3664,7 +3664,7 @@ paths:
       parameters:
         - in: query
           name: path
-          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the user's start directory is assumed. If relative, the user's start directory is used as the base
           schema:
             type: string
       responses:
@@ -4679,6 +4679,9 @@ components:
         external_auth_cache_time:
           type: integer
           description: 'Defines the cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache'
+        start_directory:
+          type: string
+          description: 'Specifies an alternate starting directory. If not set, the default is "/". This option is supported for SFTP/SCP, FTP and HTTP (WebClient/REST API) protocols. Relative paths will use this directory as base.'
       description: Additional user options
     Secret:
       type: object

+ 2 - 1
sftpd/internal_test.go

@@ -404,7 +404,8 @@ func TestSSHCommandPath(t *testing.T) {
 		ReadError:    nil,
 	}
 	connection := &Connection{
-		channel: &mockSSHChannel,
+		channel:        &mockSSHChannel,
+		BaseConnection: common.NewBaseConnection("", common.ProtocolSSH, "", "", dataprovider.User{}),
 	}
 	sshCommand := sshCommand{
 		command:    "test",

+ 2 - 1
sftpd/server.go

@@ -553,7 +553,8 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co
 	defer common.Connections.Remove(connection.GetID())
 
 	// Create the server instance for the channel using the handler we created above.
-	server := sftp.NewRequestServer(channel, c.createHandlers(connection), sftp.WithRSAllocator())
+	server := sftp.NewRequestServer(channel, c.createHandlers(connection), sftp.WithRSAllocator(),
+		sftp.WithStartDirectory(connection.User.Filters.StartDirectory))
 
 	defer server.Close()
 	if err := server.Serve(); err == io.EOF {

+ 95 - 0
sftpd/sftpd_test.go

@@ -538,6 +538,68 @@ func TestBasicSFTPFsHandling(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestStartDirectory(t *testing.T) {
+	usePubKey := false
+	startDir := "/st@ rt/dir"
+	u := getTestUser(usePubKey)
+	u.Filters.StartDirectory = startDir
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		currentDir, err := client.Getwd()
+		assert.NoError(t, err)
+		assert.Equal(t, startDir, currentDir)
+
+		entries, err := client.ReadDir(".")
+		assert.NoError(t, err)
+		assert.Len(t, entries, 0)
+
+		testFilePath := filepath.Join(homeBasePath, testFileName)
+		testFileSize := int64(65535)
+		err = createTestFile(testFilePath, testFileSize)
+		assert.NoError(t, err)
+		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
+		assert.NoError(t, err)
+		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+		err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
+		assert.NoError(t, err)
+		_, err = client.Stat(testFileName)
+		assert.NoError(t, err)
+		err = client.Rename(testFileName, testFileName+"_rename")
+		assert.NoError(t, err)
+
+		entries, err = client.ReadDir(".")
+		assert.NoError(t, err)
+		assert.Len(t, entries, 1)
+
+		currentDir, err = client.RealPath("..")
+		assert.NoError(t, err)
+		assert.Equal(t, path.Dir(startDir), currentDir)
+
+		currentDir, err = client.RealPath("../..")
+		assert.NoError(t, err)
+		assert.Equal(t, "/", currentDir)
+
+		currentDir, err = client.RealPath("../../..")
+		assert.NoError(t, err)
+		assert.Equal(t, "/", currentDir)
+
+		err = os.Remove(testFilePath)
+		assert.NoError(t, err)
+		err = os.Remove(localDownloadPath)
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestFolderPrefix(t *testing.T) {
 	usePubKey := true
 	u := getTestUser(usePubKey)
@@ -9184,6 +9246,39 @@ func TestSCPRecursive(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestSCPStartDirectory(t *testing.T) {
+	usePubKey := true
+	startDir := "/sta rt/dir"
+	u := getTestUser(usePubKey)
+	u.Filters.StartDirectory = startDir
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	testFileSize := int64(131072)
+	testFilePath := filepath.Join(homeBasePath, testFileName)
+	localPath := filepath.Join(homeBasePath, "scp_download.dat")
+	remoteUpPath := fmt.Sprintf("%v@127.0.0.1:", user.Username)
+	remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, testFileName)
+	err = createTestFile(testFilePath, testFileSize)
+	assert.NoError(t, err)
+	err = scpUpload(testFilePath, remoteUpPath, false, false)
+	assert.NoError(t, err)
+	err = scpDownload(localPath, remoteDownPath, false, false)
+	assert.NoError(t, err)
+	// check that the file is in the start directory
+	_, err = os.Stat(filepath.Join(user.HomeDir, startDir, testFileName))
+	assert.NoError(t, err)
+
+	err = os.Remove(testFilePath)
+	assert.NoError(t, err)
+	err = os.Remove(localPath)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestSCPPatternsFilter(t *testing.T) {
 	if len(scpPath) == 0 {
 		t.Skip("scp command not found, unable to execute this test")

+ 4 - 4
sftpd/ssh_cmd.go

@@ -531,7 +531,7 @@ func (c *sshCommand) getDestPath() string {
 	if len(c.args) == 0 {
 		return ""
 	}
-	return cleanCommandPath(c.args[len(c.args)-1])
+	return c.cleanCommandPath(c.args[len(c.args)-1])
 }
 
 // for the supported commands, the destination path, if any, is the second-last argument
@@ -539,13 +539,13 @@ func (c *sshCommand) getSourcePath() string {
 	if len(c.args) < 2 {
 		return ""
 	}
-	return cleanCommandPath(c.args[len(c.args)-2])
+	return c.cleanCommandPath(c.args[len(c.args)-2])
 }
 
-func cleanCommandPath(name string) string {
+func (c *sshCommand) cleanCommandPath(name string) string {
 	name = strings.Trim(name, "'")
 	name = strings.Trim(name, "\"")
-	result := util.CleanPath(name)
+	result := c.connection.User.GetCleanedPath(name)
 	if strings.HasSuffix(name, "/") && !strings.HasSuffix(result, "/") {
 		result += "/"
 	}

+ 11 - 0
templates/webadmin/user.html

@@ -821,6 +821,17 @@
                     <div id="collapseAdvanced" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionUser">
                         <div class="card-body">
 
+                            <div class="form-group row">
+                                <label for="idStartDirectory" class="col-sm-2 col-form-label">Start directory</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idStartDirectory" name="start_directory" placeholder=""
+                                        value="{{.User.Filters.StartDirectory}}" aria-describedby="startDirHelpBlock">
+                                    <small id="startDirHelpBlock" class="form-text text-muted">
+                                        Alternate start directory to use instead of "/". Supported for SFTP/FTP/HTTP
+                                    </small>
+                                </div>
+                            </div>
+
                             <div class="form-group row">
                                 <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
                                 <div class="col-sm-10">

+ 1 - 1
vfs/vfs.go

@@ -83,7 +83,7 @@ type Fs interface {
 	IsUploadResumeSupported() bool
 	IsAtomicUploadSupported() bool
 	CheckRootPath(username string, uid int, gid int) bool
-	ResolvePath(sftpPath string) (string, error)
+	ResolvePath(virtualPath string) (string, error)
 	IsNotExist(err error) bool
 	IsPermission(err error) bool
 	IsNotSupported(err error) bool