diff --git a/docs/ssh-commands.md b/docs/ssh-commands.md index c990f17c..c86ae88f 100644 --- a/docs/ssh-commands.md +++ b/docs/ssh-commands.md @@ -37,7 +37,7 @@ SFTPGo supports the following built-in SSH commands: - `scp`, SFTPGo implements the SCP protocol so we can support it for cloud filesystems too and we can avoid the other system commands limitations. SCP between two remote hosts is supported using the `-3` scp option. Wildcard expansion is not supported. - `md5sum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. Useful to check message digests for uploaded files. - `cd`, `pwd`. Some SFTP clients do not support the SFTP SSH_FXP_REALPATH packet type, so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` does nothing and `pwd` always returns the `/` path. These commands will work with any storage backend but keep in mind that to calculate the hash we need to read the whole file, for remote backends this means downloading the file, for the encrypted backend this means decrypting the file. -- `sftpgo-copy`. This is a built-in copy implementation. It allows server side copy for files and directories. The first argument is the source file/directory and the second one is the destination file/directory, for example `sftpgo-copy `. :warning: Copying directories that span virtual folders is supported but, for Cloud Storage filesystems, the remote copy API is not currently used. +- `sftpgo-copy`. This is a built-in copy implementation. It allows server side copy for files and directories. The first argument is the source file/directory and the second one is the destination file/directory, for example `sftpgo-copy `. - `sftpgo-remove`. This is a built-in remove implementation. It allows to remove single files and to recursively remove directories. The first argument is the file/directory to remove, for example `sftpgo-remove `. Removing directories spanning virtual folders is not supported. The following SSH commands are enabled by default: diff --git a/go.mod b/go.mod index 81157105..701ef19f 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,9 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.27.0 github.com/aws/aws-sdk-go-v2/credentials v1.17.0 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.1 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.2 github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.20.1 - github.com/aws/aws-sdk-go-v2/service/s3 v1.50.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.50.1 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.27.1 github.com/aws/aws-sdk-go-v2/service/sts v1.27.0 github.com/bmatcuk/doublestar/v4 v4.6.1 @@ -39,7 +39,7 @@ require ( github.com/jackc/pgx/v5 v5.5.3 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 github.com/klauspost/compress v1.17.6 - github.com/lestrrat-go/jwx/v2 v2.0.19 + github.com/lestrrat-go/jwx/v2 v2.0.20 github.com/lithammer/shortuuid/v3 v3.0.7 github.com/mattn/go-sqlite3 v1.14.22 github.com/mhale/smtpd v0.8.2 @@ -144,7 +144,7 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect + github.com/power-devops/perfstat v0.0.0-20240219145905-2259734c190a // indirect github.com/prometheus/client_model v0.6.0 // indirect github.com/prometheus/common v0.47.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect diff --git a/go.sum b/go.sum index 5a3d330d..abef5690 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.0 h1:lMW2x6sKBsiAJrpi1doOXqWFyEPo github.com/aws/aws-sdk-go-v2/credentials v1.17.0/go.mod h1:uT41FIH8cCIxOdUYIL0PYyHlL1NoneDuDSCwg5VE/5o= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 h1:xWCwjjvVz2ojYTP4kBKUuUh9ZrXfcAXpflhOUUeXg1k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0/go.mod h1:j3fACuqXg4oMTQOR2yY7m0NmJY0yBK4L4sLsRXq1Ins= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.1 h1:FqtJUSBgT2yfZ8kZhTi9AO131qMLOzb4MiH4riAM8XM= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.1/go.mod h1:G3V4qNUPMHKrXW/l149QXmHjf1vlMWBO4UuGPCK4a/c= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.2 h1:VEekE/fJWqAWYozxFQ07B+h8NdvTPAYhV13xIBenuO0= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.2/go.mod h1:8vozqAHmDNmoD4YbuDKIfpnLbByzngczL4My1RELLVo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.0 h1:NPs/EqVO+ajwOoq56EfcGKa3L3ruWuazkIw1BqxwOPw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.0/go.mod h1:D+duLy2ylgatV+yTlQ8JTuLfDD0BnFvnQRc+o6tbZ4M= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.0 h1:ks7KGMVUMoDzcxNWUlEdI+/lokMFD136EL6DWmUOV80= @@ -63,8 +63,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.0 h1:l5puwOHr7IxECu github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.0/go.mod h1:Oov79flWa/n7Ni+lQC3z+VM7PoRM47omRqbJU9B5Y7E= github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.20.1 h1:eHNChn4Sp+g1hdz4rkx96n1l/LpJEQLDuFB0V+fA/yg= github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.20.1/go.mod h1:9ev55pJx9xNX3UAOKzZmbmaTbwwuLTCemOJPsd7rUz8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.50.0 h1:jZAdMD1ioZdqirzzVVRhpHHWJmcGGCn8JqDYBs5nmYA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.50.0/go.mod h1:1o/W6JFUuREj2ExoQ21vHJgO7wakvjhol91M9eknFgs= +github.com/aws/aws-sdk-go-v2/service/s3 v1.50.1 h1:bjpWJEXch7moIt3PX2r5XpGROsletl7enqG1Q3Te1Dc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.50.1/go.mod h1:1o/W6JFUuREj2ExoQ21vHJgO7wakvjhol91M9eknFgs= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.27.1 h1:ss/HbHbONu0uscM549++4YanT6MnjNN0BGhE5pZRfG4= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.27.1/go.mod h1:JsJDZFHwLGZu6dxhV9EV1gJrMnCeE4GEXubSZA59xdA= github.com/aws/aws-sdk-go-v2/service/sso v1.19.0 h1:u6OkVDxtBPnxPkZ9/63ynEe+8kHbtS5IfaC4PzVxzWM= @@ -270,8 +270,8 @@ github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJG github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.0.19 h1:ekv1qEZE6BVct89QA+pRF6+4pCpfVrOnEJnTnT4RXoY= -github.com/lestrrat-go/jwx/v2 v2.0.19/go.mod h1:l3im3coce1lL2cDeAjqmaR+Awx+X8Ih+2k8BuHNJ4CU= +github.com/lestrrat-go/jwx/v2 v2.0.20 h1:sAgXuWS/t8ykxS9Bi2Qtn5Qhpakw1wrcjxChudjolCc= +github.com/lestrrat-go/jwx/v2 v2.0.20/go.mod h1:UlCSmKqw+agm5BsOBfEAbTvKsEApaGNqHAEUTv5PJC4= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -322,8 +322,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= -github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240219145905-2259734c190a h1:XCUtNgBnZfUBhdfCX2QK+fslr9vevSsUg3W3peZwlak= +github.com/power-devops/perfstat v0.0.0-20240219145905-2259734c190a/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= diff --git a/internal/common/connection.go b/internal/common/connection.go index dd49008f..901b3c13 100644 --- a/internal/common/connection.go +++ b/internal/common/connection.go @@ -612,6 +612,9 @@ func (c *BaseConnection) checkCopy(srcInfo, dstInfo os.FileInfo, virtualSource, } func (c *BaseConnection) copyFile(virtualSourcePath, virtualTargetPath string, srcSize int64) error { + if !c.User.HasPerm(dataprovider.PermCopy, virtualSourcePath) || !c.User.HasPerm(dataprovider.PermCopy, virtualTargetPath) { + return c.GetPermissionDeniedError() + } if ok, _ := c.User.IsFileAllowed(virtualTargetPath); !ok { return fmt.Errorf("file %q is not allowed: %w", virtualTargetPath, c.GetPermissionDeniedError()) } diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index 6d34b65d..38060f23 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -8601,6 +8601,13 @@ func TestCopyAndRemovePermissions(t *testing.T) { user.Permissions[testDir] = []string{dataprovider.PermListItems} _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) + // no copy permission + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, testFileName, restrictedPath), user) + assert.Error(t, err, string(out)) + user.Permissions[restrictedPath] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermCopy} + user.Permissions[testDir] = []string{dataprovider.PermListItems, dataprovider.PermCopy} + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, testFileName, restrictedPath), user) assert.NoError(t, err, string(out)) // overwrite will fail, no permission diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go index cef652a1..52f7a43e 100644 --- a/internal/dataprovider/dataprovider.go +++ b/internal/dataprovider/dataprovider.go @@ -160,8 +160,8 @@ var ( BoltDataProviderName, MemoryDataProviderName, CockroachDataProviderName} // ValidPerms defines all the valid permissions for a user ValidPerms = []string{PermAny, PermListItems, PermDownload, PermUpload, PermOverwrite, PermCreateDirs, PermRename, - PermRenameFiles, PermRenameDirs, PermDelete, PermDeleteFiles, PermDeleteDirs, PermCreateSymlinks, PermChmod, - PermChown, PermChtimes} + PermRenameFiles, PermRenameDirs, PermDelete, PermDeleteFiles, PermDeleteDirs, PermCopy, PermCreateSymlinks, + PermChmod, PermChown, PermChtimes} // ValidLoginMethods defines all the valid login methods ValidLoginMethods = []string{SSHLoginMethodPublicKey, LoginMethodPassword, SSHLoginMethodPassword, SSHLoginMethodKeyboardInteractive, SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt, diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index d1c40513..20fb5c06 100644 --- a/internal/dataprovider/user.go +++ b/internal/dataprovider/user.go @@ -72,6 +72,8 @@ const ( PermChown = "chown" // changing file or directory access and modification time is allowed PermChtimes = "chtimes" + // copying files or directories is allowed + PermCopy = "copy" ) // Available login methods @@ -1113,6 +1115,21 @@ func (u *User) CanDeleteFromWeb(target string) bool { return u.HasAnyPerm(permsDeleteAny, target) } +// CanCopyFromWeb returns true if the client can copy objects from the web UI. +// The specified src and dest are the source and target directories for the copy. +func (u *User) CanCopyFromWeb(src, dest string) bool { + if util.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) { + return false + } + if !u.HasPerm(PermListItems, src) { + return false + } + if !u.HasPerm(PermDownload, src) { + return false + } + return u.HasPerm(PermCopy, src) && u.HasPerm(PermCopy, dest) +} + // PasswordExpiresIn returns the number of days before the password expires. // The returned value is negative if the password is expired. // The caller must ensure that a PasswordExpiration is set diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index f42d8f11..894ef21a 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -16223,6 +16223,21 @@ func TestRenameDifferentResource(t *testing.T) { setBearerForReq(req, webAPIToken) rr = executeRequest(req) checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Cannot perform copy step") + + u.Permissions = map[string][]string{ + "/": {dataprovider.PermUpload, dataprovider.PermListItems, dataprovider.PermCreateDirs, + dataprovider.PermDownload, dataprovider.PermOverwrite, dataprovider.PermCopy}, + } + _, resp, err = httpdtest.UpdateUser(u, http.StatusOK, "") + assert.NoError(t, err, string(resp)) + webAPIToken, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path="+testFileName+"&target="+url.QueryEscape(path.Join("/", "folderPath", testFileName)), nil) + assert.NoError(t, err) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) assert.Contains(t, rr.Body.String(), "Cannot perform remove step") _, err = httpdtest.RemoveUser(user, http.StatusOK) diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index 03a8a409..c407e2b2 100644 --- a/internal/httpd/webclient.go +++ b/internal/httpd/webclient.go @@ -135,6 +135,7 @@ type filesPage struct { CanDelete bool CanDownload bool CanShare bool + CanCopy bool ShareUploadBaseURL string Error *util.I18nError Paths []dirMapping @@ -755,6 +756,7 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque CanDelete: false, CanDownload: share.Scope != dataprovider.ShareScopeWrite, CanShare: false, + CanCopy: false, Paths: getDirMapping(dirName, currentURL), QuotaUsage: newUserQuotaUsage(&dataprovider.User{}), } @@ -797,6 +799,7 @@ func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, di CanDelete: user.CanDeleteFromWeb(dirName), CanDownload: user.HasPerm(dataprovider.PermDownload, dirName), CanShare: user.CanManageShares(), + CanCopy: user.CanCopyFromWeb(dirName, dirName), ShareUploadBaseURL: "", Paths: getDirMapping(dirName, webClientFilesPath), QuotaUsage: newUserQuotaUsage(user), diff --git a/internal/sftpd/sftpd_test.go b/internal/sftpd/sftpd_test.go index 95422d9e..9e42fd4f 100644 --- a/internal/sftpd/sftpd_test.go +++ b/internal/sftpd/sftpd_test.go @@ -9280,7 +9280,7 @@ func TestSSHCopyPermissions(t *testing.T) { u := getTestUser(usePubKey) u.Permissions["/dir1"] = []string{dataprovider.PermUpload, dataprovider.PermDownload, dataprovider.PermListItems} u.Permissions["/dir2"] = []string{dataprovider.PermCreateDirs, dataprovider.PermUpload, dataprovider.PermDownload, - dataprovider.PermListItems} + dataprovider.PermListItems, dataprovider.PermCopy} u.Permissions["/dir3"] = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermDownload, dataprovider.PermListItems} user, _, err := httpdtest.AddUser(u, http.StatusCreated) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 66fd3568..ba904785 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4904,6 +4904,7 @@ components: - chmod - chown - chtimes + - copy description: | Permissions: * `*` - all permissions are granted @@ -4922,6 +4923,7 @@ components: * `chmod` changing file or directory permissions is allowed * `chown` changing file or directory owner and group is allowed * `chtimes` changing file or directory access and modification time is allowed + * `copy`, copying files or directories is allowed AdminPermissions: type: string enum: diff --git a/templates/webclient/files.html b/templates/webclient/files.html index 7892d8d5..3fc71f24 100644 --- a/templates/webclient/files.html +++ b/templates/webclient/files.html @@ -17,6 +17,20 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). {{- define "page_body"}} {{- template "errmsg" .Error}} + +{{- $move_copy_msg := ""}} +{{- if and .CanRename .CanCopy}} +{{- $move_copy_msg = "fs.move_copy"}} +{{- else}} +{{- if .CanRename}} +{{- $move_copy_msg = "fs.move.msg"}} +{{- else}} +{{- if .CanCopy}} +{{- $move_copy_msg = "fs.copy.msg"}} +{{- end}} +{{- end}} +{{- end}} +
@@ -91,7 +105,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). {{- if not .ShareUploadBaseURL}} {{- if or .CanRename .CanAddFiles}} @@ -208,6 +222,19 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).