diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 6ade0bcb..540cfd9b 100644 --- a/.github/workflows/development.yml +++ b/.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: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4f664cea..986b8e08 100644 --- a/.github/workflows/docker.yml +++ b/.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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c38201d5..70da2b66 100644 --- a/.github/workflows/release.yml +++ b/.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: | diff --git a/cmd/portable.go b/cmd/portable.go index 83fbae74..3f6ecd4c 100644 --- a/cmd/portable.go +++ b/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, diff --git a/common/common_test.go b/common/common_test.go index 596a101b..65626d77 100644 --- a/common/common_test.go +++ b/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/")) diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 656cf491..253be000 100644 --- a/dataprovider/dataprovider.go +++ b/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) } diff --git a/dataprovider/user.go b/dataprovider/user.go index 95ac0fd5..1499f52b 100644 --- a/dataprovider/user.go +++ b/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)) diff --git a/docs/defender.md b/docs/defender.md index 38fcdd57..7a0742dd 100644 --- a/docs/defender.md +++ b/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: diff --git a/docs/portable-mode.md b/docs/portable-mode.md index 7e155c04..91ac8123 100644 --- a/docs/portable-mode.md +++ b/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 diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index 0e943224..143838ac 100644 --- a/ftpd/ftpd_test.go +++ b/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) diff --git a/ftpd/internal_test.go b/ftpd/internal_test.go index 12b84bc1..6e43565d 100644 --- a/ftpd/internal_test.go +++ b/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 { diff --git a/ftpd/server.go b/ftpd/server.go index 8c0fdf60..8bb40ed7 100644 --- a/ftpd/server.go +++ b/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 { diff --git a/go.mod b/go.mod index 758fe8c9..fa7b0630 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 19682728..629b1a6d 100644 --- a/go.sum +++ b/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= diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go index 8021706c..8be88184 100644 --- a/httpd/api_http_user.go +++ b/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)) diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 478ec305..6749fda6 100644 --- a/httpd/api_utils.go +++ b/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 } diff --git a/httpd/handler.go b/httpd/handler.go index f7e2b45d..26c35e83 100644 --- a/httpd/handler.go +++ b/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() } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 3189a12a..63d8b0b0 100644 --- a/httpd/httpd_test.go +++ b/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)) diff --git a/httpd/webadmin.go b/httpd/webadmin.go index 612f40d0..019d4fde 100644 --- a/httpd/webadmin.go +++ b/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 } diff --git a/httpd/webclient.go b/httpd/webclient.go index 45febd21..33ae727b 100644 --- a/httpd/webclient.go +++ b/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), "", diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index abc9b177..bb75269a 100644 --- a/httpdtest/httpdtest.go +++ b/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 } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index eb7fe050..31468d4e 100644 --- a/openapi/openapi.yaml +++ b/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 diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index 76e00d32..8fe11426 100644 --- a/sftpd/internal_test.go +++ b/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", diff --git a/sftpd/server.go b/sftpd/server.go index 3323b573..77d933d4 100644 --- a/sftpd/server.go +++ b/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 { diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 4b61748b..df0c28de 100644 --- a/sftpd/sftpd_test.go +++ b/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") diff --git a/sftpd/ssh_cmd.go b/sftpd/ssh_cmd.go index bfcac9fb..199d9283 100644 --- a/sftpd/ssh_cmd.go +++ b/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 += "/" } diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index 6e05f8f6..94038fd9 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -821,6 +821,17 @@