diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go index 4986c673..71346e46 100644 --- a/httpd/api_http_user.go +++ b/httpd/api_http_user.go @@ -181,6 +181,7 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest) return } + defer r.MultipartForm.RemoveAll() //nolint:errcheck parentDir := util.CleanPath(r.URL.Query().Get("path")) files := r.MultipartForm.File["filename"] diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index cbc566bf..5034522f 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -373,7 +373,8 @@ func TestBasicUserHandling(t *testing.T) { user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now()) user.AdditionalInfo = "some free text" user.Filters.TLSUsername = sdk.TLSUsernameCN - user.Filters.WebClient = append(user.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled) + user.Filters.WebClient = append(user.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled, + sdk.WebClientWriteDisabled) originalUser := user user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) @@ -6352,6 +6353,86 @@ func TestWebAPIVFolder(t *testing.T) { assert.NoError(t, err) } +func TestWebAPIWritePermission(t *testing.T) { + u := getTestUser() + u.Filters.WebClient = append(u.Filters.WebClient, sdk.WebClientWriteDisabled) + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("filename", "file.txt") + assert.NoError(t, err) + _, err = part.Write([]byte("")) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + reader := bytes.NewReader(body.Bytes()) + + req, err := http.NewRequest(http.MethodPost, userFilePath, reader) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, err = http.NewRequest(http.MethodPatch, userFilePath+"?path=a&target=b", nil) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, err = http.NewRequest(http.MethodDelete, userFilePath+"?path=a", nil) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, err = http.NewRequest(http.MethodGet, userFilePath+"?path=a.txt", nil) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, userFolderPath, nil) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodPost, userFolderPath+"?path=dir", nil) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, err = http.NewRequest(http.MethodPatch, userFolderPath+"?path=dir&target=dir1", nil) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, err = http.NewRequest(http.MethodDelete, userFolderPath+"?path=dir", nil) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestWebAPICryptFs(t *testing.T) { u := getTestUser() u.QuotaSize = 65535 diff --git a/httpd/middleware.go b/httpd/middleware.go index 98d49e9b..90cab4a3 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -123,7 +123,6 @@ func jwtAuthenticatorWebClient(next http.Handler) http.Handler { }) } -//nolint:unparam func checkHTTPUserPerm(perm string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index dcde7161..c0a5790a 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2228,9 +2228,11 @@ components: type: string enum: - publickey-change-disabled + - write-disabled description: | Options: * `publickey-change-disabled` - changing SSH public keys is not allowed + * `write-disabled` - upload, rename, delete are not allowed even if the user has permissions for these actions PatternsFilter: type: object properties: @@ -2321,7 +2323,7 @@ components: type: array items: $ref: '#/components/schemas/WebClientOptions' - description: WebClient related configuration options + description: WebClient/user REST API related configuration options description: Additional user options Secret: type: object diff --git a/httpd/server.go b/httpd/server.go index 9807a10a..68155683 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -632,13 +632,13 @@ func (s *httpdServer) initializeRouter() { router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Get(userPublicKeysPath, getUserPublicKeys) router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Put(userPublicKeysPath, setUserPublicKeys) router.Get(userFolderPath, readUserFolder) - router.Post(userFolderPath, createUserDir) - router.Patch(userFolderPath, renameUserDir) - router.Delete(userFolderPath, deleteUserDir) + router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userFolderPath, createUserDir) + router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFolderPath, renameUserDir) + router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFolderPath, deleteUserDir) router.Get(userFilePath, getUserFile) - router.Post(userFilePath, uploadUserFiles) - router.Patch(userFilePath, renameUserFile) - router.Delete(userFilePath, deleteUserFile) + router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userFilePath, uploadUserFiles) + router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilePath, renameUserFile) + router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFilePath, deleteUserFile) router.Post(userStreamZipPath, getUserFilesAsZipStream) }) diff --git a/sdk/user.go b/sdk/user.go index ea14d106..d042c756 100644 --- a/sdk/user.go +++ b/sdk/user.go @@ -6,14 +6,15 @@ import ( "github.com/drakkan/sftpgo/v2/util" ) -// Web Client restrictions +// Web Client/user REST API restrictions const ( WebClientPubKeyChangeDisabled = "publickey-change-disabled" + WebClientWriteDisabled = "write-disabled" ) var ( - // WebClientOptions defines the available options for the web client interface - WebClientOptions = []string{WebClientPubKeyChangeDisabled} + // WebClientOptions defines the available options for the web client interface/user REST API + WebClientOptions = []string{WebClientPubKeyChangeDisabled, WebClientWriteDisabled} ) // TLSUsername defines the TLS certificate attribute to use as username diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index a87af03e..afc053d9 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -538,7 +538,7 @@
- +