From e9dd4ecdf04d4783e1a9ddfa67db5839704e61e4 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Wed, 3 Feb 2021 08:55:28 +0100 Subject: [PATCH] web admin: add CSRF --- httpd/auth_utils.go | 40 +++++- httpd/httpd.go | 4 + httpd/httpd_test.go | 269 +++++++++++++++++++++++++++++++++++-- httpd/internal_test.go | 47 +++++++ httpd/middleware.go | 39 +++++- httpd/server.go | 21 ++- httpd/web.go | 46 +++++++ templates/admin.html | 1 + templates/admins.html | 1 + templates/changepwd.html | 1 + templates/connections.html | 1 + templates/folder.html | 1 + templates/folders.html | 2 + templates/login.html | 2 +- templates/maintenance.html | 1 + templates/user.html | 6 +- templates/users.html | 2 + 17 files changed, 459 insertions(+), 25 deletions(-) diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index f3b050e6..cebbb2ea 100644 --- a/httpd/auth_utils.go +++ b/httpd/auth_utils.go @@ -1,6 +1,8 @@ package httpd import ( + "errors" + "fmt" "net/http" "time" @@ -9,14 +11,16 @@ import ( "github.com/rs/xid" "github.com/drakkan/sftpgo/dataprovider" + "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" ) type tokenAudience = string const ( - tokenAudienceWeb tokenAudience = "Web" - tokenAudienceAPI tokenAudience = "API" + tokenAudienceWeb tokenAudience = "Web" + tokenAudienceAPI tokenAudience = "API" + tokenAudienceCSRF tokenAudience = "CSRF" ) const ( @@ -186,3 +190,35 @@ func getAdminFromToken(r *http.Request) *dataprovider.Admin { admin.Permissions = tokenClaims.Permissions return admin } + +func createCSRFToken() string { + claims := make(map[string]interface{}) + now := time.Now().UTC() + + claims[jwt.JwtIDKey] = xid.New().String() + claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) + claims[jwt.ExpirationKey] = now.Add(tokenDuration) + claims[jwt.AudienceKey] = tokenAudienceCSRF + + _, tokenString, err := csrfTokenAuth.Encode(claims) + if err != nil { + logger.Debug(logSender, "", "unable to create CSRF token: %v", err) + return "" + } + return tokenString +} + +func verifyCSRFToken(tokenString string) error { + token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString) + if err != nil || token == nil { + logger.Debug(logSender, "", "error validating CSRF: %v", err) + return fmt.Errorf("Unable to verify form token: %v", err) + } + + if !utils.IsStringInSlice(tokenAudienceCSRF, token.Audience()) { + logger.Debug(logSender, "", "error validating CSRF token audience") + return errors.New("The form token is not valid") + } + + return nil +} diff --git a/httpd/httpd.go b/httpd/httpd.go index c983dc2b..7a665f6e 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -15,6 +15,7 @@ import ( "time" "github.com/go-chi/chi" + "github.com/go-chi/jwtauth" "github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/dataprovider" @@ -77,6 +78,7 @@ var ( jwtTokensCleanupTicker *time.Ticker jwtTokensCleanupDone chan bool invalidatedJWTTokens sync.Map + csrfTokenAuth *jwtauth.JWTAuth ) // Binding defines the configuration for a network listener @@ -205,6 +207,8 @@ func (c *Conf) Initialize(configDir string) error { certMgr = mgr } + csrfTokenAuth = jwtauth.New("HS256", utils.GenerateRandomBytes(32), nil) + exitChannel := make(chan error, 1) for _, binding := range c.Bindings { diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 6ec8f0db..d0ae6b22 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -29,6 +29,7 @@ import ( "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/net/html" "github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/config" @@ -50,6 +51,7 @@ const ( defaultTokenAuthPass = "password" altAdminUsername = "newTestAdmin" altAdminPassword = "password1" + csrfFormToken = "_form_token" userPath = "/api/v2/users" adminPath = "/api/v2/admins" adminPwdPath = "/api/v2/changepwd/admin" @@ -3899,8 +3901,10 @@ func TestWebLoginMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusFound, rr) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) // now try using wrong credentials - form := getAdminLoginForm(defaultTokenAuthUser, "wrong pwd") + form := getAdminLoginForm(defaultTokenAuthUser, "wrong pwd", csrfToken) req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) @@ -3914,7 +3918,7 @@ func TestWebLoginMock(t *testing.T) { _, _, err = httpdtest.AddAdmin(a, http.StatusCreated) assert.NoError(t, err) - form = getAdminLoginForm(altAdminUsername, altAdminPassword) + form = getAdminLoginForm(altAdminUsername, altAdminPassword, csrfToken) req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = "127.1.1.1:1234" @@ -3936,6 +3940,15 @@ func TestWebLoginMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), "Login from IP 127.0.1.1:4567 is not allowed") + // invalid csrf token + form = getAdminLoginForm(altAdminUsername, altAdminPassword, "invalid csrf") + req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.9.9.8:1234" + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + req, _ = http.NewRequest(http.MethodGet, webLoginPath, nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) @@ -3973,6 +3986,8 @@ func TestWebAdminPwdChange(t *testing.T) { token, err := getJWTWebTokenFromTestServer(admin.Username, altAdminPassword) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) req, _ := http.NewRequest(http.MethodGet, webChangeAdminPwdPath, nil) setJWTCookieForReq(req, token) rr := executeRequest(req) @@ -3981,6 +3996,15 @@ func TestWebAdminPwdChange(t *testing.T) { form.Set("current_password", altAdminPassword) form.Set("new_password1", altAdminPassword) form.Set("new_password2", altAdminPassword) + // no csrf token + req, _ = http.NewRequest(http.MethodPost, webChangeAdminPwdPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) req, _ = http.NewRequest(http.MethodPost, webChangeAdminPwdPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") setJWTCookieForReq(req, token) @@ -4047,8 +4071,11 @@ func TestBasicWebUsersMock(t *testing.T) { setJWTCookieForReq(req, webToken) rr = executeRequest(req) checkResponseCode(t, http.StatusNotFound, rr) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) form := make(url.Values) form.Set("username", user.Username) + form.Set(csrfFormToken, csrfToken) b, contentType, _ := getMultipartFormData(form, "", "") req, _ = http.NewRequest(http.MethodPost, webUserPath, &b) setJWTCookieForReq(req, webToken) @@ -4075,9 +4102,16 @@ func TestBasicWebUsersMock(t *testing.T) { req, _ = http.NewRequest(http.MethodDelete, path.Join(webUserPath, user.Username), nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid token") + req, _ = http.NewRequest(http.MethodDelete, path.Join(webUserPath, user.Username), nil) + setJWTCookieForReq(req, webToken) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) req, _ = http.NewRequest(http.MethodDelete, path.Join(webUserPath, user1.Username), nil) setJWTCookieForReq(req, webToken) + setCSRFHeaderForReq(req, csrfToken) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) } @@ -4088,15 +4122,26 @@ func TestWebAdminBasicMock(t *testing.T) { admin := getTestAdmin() admin.Username = altAdminUsername admin.Password = altAdminPassword + csrfToken, err := getCSRFToken() + assert.NoError(t, err) form := make(url.Values) form.Set("username", admin.Username) form.Set("password", "") - form.Set("status", "a") // invalid status + form.Set("status", "1") form.Set("permissions", "*") req, _ := http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") setJWTCookieForReq(req, token) rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + form.Set("status", "a") + req, _ = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) form.Set("status", "1") @@ -4137,6 +4182,15 @@ func TestWebAdminBasicMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusSeeOther, rr) + form.Set(csrfFormToken, "invalid csrf") + req, _ = http.NewRequest(http.MethodPost, path.Join(webAdminPath, altAdminUsername), bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) form.Set("email", "not-an-email") req, _ = http.NewRequest(http.MethodPost, path.Join(webAdminPath, altAdminUsername), bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -4178,6 +4232,7 @@ func TestWebAdminBasicMock(t *testing.T) { req, _ = http.NewRequest(http.MethodDelete, path.Join(webAdminPath, altAdminUsername), nil) setJWTCookieForReq(req, token) + setCSRFHeaderForReq(req, csrfToken) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) @@ -4186,9 +4241,16 @@ func TestWebAdminBasicMock(t *testing.T) { req, _ = http.NewRequest(http.MethodDelete, path.Join(webAdminPath, defaultTokenAuthUser), nil) setJWTCookieForReq(req, token) + setCSRFHeaderForReq(req, csrfToken) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) assert.Contains(t, rr.Body.String(), "You cannot delete yourself") + + req, _ = http.NewRequest(http.MethodDelete, path.Join(webAdminPath, defaultTokenAuthUser), nil) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid token") } func TestWebAdminPermissions(t *testing.T) { @@ -4267,12 +4329,15 @@ func TestAdminUpdateSelfMock(t *testing.T) { assert.NoError(t, err) token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) form := make(url.Values) form.Set("username", admin.Username) form.Set("password", admin.Password) form.Set("status", "0") form.Set("permissions", dataprovider.PermAdminAddUsers) form.Set("permissions", dataprovider.PermAdminCloseConnections) + form.Set(csrfFormToken, csrfToken) req, _ := http.NewRequest(http.MethodPost, path.Join(webAdminPath, defaultTokenAuthUser), bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") setJWTCookieForReq(req, token) @@ -4296,6 +4361,8 @@ func TestWebMaintenanceMock(t *testing.T) { setJWTCookieForReq(req, token) rr := executeRequest(req) checkResponseCode(t, http.StatusOK, rr) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) form := make(url.Values) form.Set("mode", "a") @@ -4304,6 +4371,15 @@ func TestWebMaintenanceMock(t *testing.T) { setJWTCookieForReq(req, token) req.Header.Set("Content-Type", contentType) rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + b, contentType, _ = getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, webRestorePath, &b) + setJWTCookieForReq(req, token) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) form.Set("mode", "0") @@ -4388,6 +4464,8 @@ func TestWebUserAddMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() user.UploadBandwidth = 32 user.DownloadBandwidth = 64 @@ -4407,6 +4485,7 @@ func TestWebUserAddMock(t *testing.T) { checkResponseCode(t, http.StatusCreated, rr) form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) form.Set("password", user.Password) @@ -4528,6 +4607,16 @@ func TestWebUserAddMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) form.Set("max_upload_file_size", "1000") + form.Set(csrfFormToken, "invalid form token") + b, contentType, _ = getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, webUserPath, &b) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) b, contentType, _ = getMultipartFormData(form, "", "") req, _ = http.NewRequest(http.MethodPost, webUserPath, &b) setJWTCookieForReq(req, webToken) @@ -4622,6 +4711,8 @@ func TestWebUserUpdateMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) @@ -4662,7 +4753,17 @@ func TestWebUserUpdateMock(t *testing.T) { setJWTCookieForReq(req, webToken) req.Header.Set("Content-Type", contentType) rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + b, contentType, _ = getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) checkResponseCode(t, http.StatusSeeOther, rr) + req, _ = http.NewRequest(http.MethodGet, path.Join(userPath, user.Username), nil) setBearerForReq(req, apiToken) rr = executeRequest(req) @@ -4755,6 +4856,8 @@ func TestUserTemplateWithFoldersMock(t *testing.T) { token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() form := make(url.Values) form.Set("username", user.Username) @@ -4779,6 +4882,15 @@ func TestUserTemplateWithFoldersMock(t *testing.T) { setJWTCookieForReq(req, token) req.Header.Set("Content-Type", contentType) rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + require.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + b, contentType, _ = getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b) + setJWTCookieForReq(req, token) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) require.Contains(t, rr.Body.String(), "invalid folder mapped path") @@ -4828,7 +4940,10 @@ func TestUserTemplateMock(t *testing.T) { user.FsConfig.S3Config.KeyPrefix = "somedir/subdir/" user.FsConfig.S3Config.UploadPartSize = 5 user.FsConfig.S3Config.UploadConcurrency = 4 + csrfToken, err := getCSRFToken() + assert.NoError(t, err) form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", user.Username) form.Set("home_dir", filepath.Join(os.TempDir(), "%username%")) form.Set("uid", "0") @@ -4929,6 +5044,8 @@ func TestWebUserS3Mock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) @@ -4948,6 +5065,7 @@ func TestWebUserS3Mock(t *testing.T) { user.FsConfig.S3Config.UploadPartSize = 5 user.FsConfig.S3Config.UploadConcurrency = 4 form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) form.Set("uid", "0") @@ -5069,6 +5187,8 @@ func TestWebUserGCSMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) req, err := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) @@ -5086,6 +5206,7 @@ func TestWebUserGCSMock(t *testing.T) { user.FsConfig.GCSConfig.KeyPrefix = "somedir/subdir/" user.FsConfig.GCSConfig.StorageClass = "standard" form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) form.Set("uid", "0") @@ -5167,6 +5288,8 @@ func TestWebUserAzureBlobMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) @@ -5185,6 +5308,7 @@ func TestWebUserAzureBlobMock(t *testing.T) { user.FsConfig.AzBlobConfig.UploadConcurrency = 4 user.FsConfig.AzBlobConfig.UseEmulator = true form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) form.Set("uid", "0") @@ -5286,6 +5410,8 @@ func TestWebUserCryptMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) @@ -5297,6 +5423,7 @@ func TestWebUserCryptMock(t *testing.T) { user.FsConfig.Provider = dataprovider.CryptedFilesystemProvider user.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret("crypted passphrase") form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) form.Set("uid", "0") @@ -5374,6 +5501,8 @@ func TestWebUserSFTPFsMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON)) @@ -5390,6 +5519,7 @@ func TestWebUserSFTPFsMock(t *testing.T) { user.FsConfig.SFTPConfig.Fingerprints = []string{sftpPkeyFingerprint} user.FsConfig.SFTPConfig.Prefix = "/home/sftpuser" form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) form.Set("uid", "0") @@ -5486,6 +5616,8 @@ func TestAddWebFoldersMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) mappedPath := filepath.Clean(os.TempDir()) folderName := filepath.Base(mappedPath) form := make(url.Values) @@ -5496,6 +5628,15 @@ func TestAddWebFoldersMock(t *testing.T) { setJWTCookieForReq(req, webToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webFolderPath, strings.NewReader(form.Encode())) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) checkResponseCode(t, http.StatusSeeOther, rr) // adding the same folder will fail since the name must be unique req, err = http.NewRequest(http.MethodPost, webFolderPath, strings.NewReader(form.Encode())) @@ -5540,6 +5681,8 @@ func TestUpdateWebFolderMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) folderName := "vfolderupdate" folder := vfs.BaseVirtualFolder{ Name: folderName, @@ -5551,11 +5694,21 @@ func TestUpdateWebFolderMock(t *testing.T) { form := make(url.Values) form.Set("mapped_path", newMappedPath) form.Set("name", folderName) + form.Set(csrfFormToken, "") req, err := http.NewRequest(http.MethodPost, path.Join(webFolderPath, folderName), strings.NewReader(form.Encode())) assert.NoError(t, err) setJWTCookieForReq(req, webToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, path.Join(webFolderPath, folderName), strings.NewReader(form.Encode())) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) checkResponseCode(t, http.StatusSeeOther, rr) // parse form error @@ -5595,8 +5748,22 @@ func TestUpdateWebFolderMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusNotFound, rr) - req, _ = http.NewRequest(http.MethodDelete, path.Join(folderPath, folderName), nil) - setBearerForReq(req, apiToken) + req, _ = http.NewRequest(http.MethodDelete, path.Join(webFolderPath, folderName), nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid token") + + req, _ = http.NewRequest(http.MethodDelete, path.Join(webFolderPath, folderName), nil) + setJWTCookieForReq(req, apiToken) // api token is not accepted + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + + req, _ = http.NewRequest(http.MethodDelete, path.Join(webFolderPath, folderName), nil) + setJWTCookieForReq(req, webToken) + setCSRFHeaderForReq(req, csrfToken) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) } @@ -5656,6 +5823,8 @@ func TestWebFoldersMock(t *testing.T) { func TestProviderClosedMock(t *testing.T) { token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) + csrfToken, err := getCSRFToken() + assert.NoError(t, err) dataprovider.Close() req, _ := http.NewRequest(http.MethodGet, webFoldersPath, nil) setJWTCookieForReq(req, token) @@ -5670,6 +5839,7 @@ func TestProviderClosedMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr) form := make(url.Values) + form.Set(csrfFormToken, csrfToken) form.Set("username", "test") req, _ = http.NewRequest(http.MethodPost, webUserPath+"/0", strings.NewReader(form.Encode())) setJWTCookieForReq(req, token) @@ -5710,13 +5880,34 @@ func TestProviderClosedMock(t *testing.T) { assert.NoError(t, err) } -func TestGetWebConnectionsMock(t *testing.T) { +func TestWebConnectionsMock(t *testing.T) { token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) req, _ := http.NewRequest(http.MethodGet, webConnectionsPath, nil) setJWTCookieForReq(req, token) rr := executeRequest(req) checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodDelete, path.Join(webConnectionsPath, "id"), nil) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid token") + + req, _ = http.NewRequest(http.MethodDelete, path.Join(webConnectionsPath, "id"), nil) + setJWTCookieForReq(req, token) + setCSRFHeaderForReq(req, "csrfToken") + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid token") + + csrfToken, err := getCSRFToken() + assert.NoError(t, err) + req, _ = http.NewRequest(http.MethodDelete, path.Join(webConnectionsPath, "id"), nil) + setJWTCookieForReq(req, token) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) } func TestGetWebStatusMock(t *testing.T) { @@ -5776,13 +5967,65 @@ func getUserAsJSON(t *testing.T, user dataprovider.User) []byte { return json } -func getAdminLoginForm(username, password string) url.Values { +func getCSRFToken() (string, error) { + req, err := http.NewRequest(http.MethodGet, httpBaseURL+webLoginPath, nil) + if err != nil { + return "", err + } + resp, err := httpclient.GetHTTPClient().Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + doc, err := html.Parse(resp.Body) + if err != nil { + return "", err + } + + var csrfToken string + var f func(*html.Node) + + f = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "input" { + var name, value string + for _, attr := range n.Attr { + if attr.Key == "value" { + value = attr.Val + } + if attr.Key == "name" { + name = attr.Val + } + } + if name == csrfFormToken { + csrfToken = value + return + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + + f(doc) + + return csrfToken, nil +} + +func getAdminLoginForm(username, password, csrfToken string) url.Values { form := make(url.Values) form.Set("username", username) form.Set("password", password) + form.Set(csrfFormToken, csrfToken) return form } +func setCSRFHeaderForReq(req *http.Request, csrfToken string) { + req.Header.Set("X-CSRF-TOKEN", csrfToken) +} + func setBearerForReq(req *http.Request, jwtToken string) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", jwtToken)) } @@ -5807,7 +6050,11 @@ func getJWTAPITokenFromTestServer(username, password string) (string, error) { } func getJWTWebToken(username, password string) (string, error) { - form := getAdminLoginForm(username, password) + csrfToken, err := getCSRFToken() + if err != nil { + return "", err + } + form := getAdminLoginForm(username, password, csrfToken) req, _ := http.NewRequest(http.MethodPost, httpBaseURL+webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -5834,7 +6081,11 @@ func getJWTWebToken(username, password string) (string, error) { } func getJWTWebTokenFromTestServer(username, password string) (string, error) { - form := getAdminLoginForm(username, password) + csrfToken, err := getCSRFToken() + if err != nil { + return "", err + } + form := getAdminLoginForm(username, password, csrfToken) req, _ := http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := executeRequest(req) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index bd2068c2..d0181f49 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -24,6 +24,7 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/jwtauth" "github.com/lestrrat-go/jwx/jwt" + "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -356,6 +357,7 @@ func TestUpdateWebAdminInvalidClaims(t *testing.T) { assert.NoError(t, err) form := make(url.Values) + form.Set(csrfFormToken, createCSRFToken()) form.Set("status", "1") req, _ := http.NewRequest(http.MethodPost, path.Join(webAdminPath, "admin"), bytes.NewBuffer([]byte(form.Encode()))) rctx := chi.NewRouteContext() @@ -368,6 +370,49 @@ func TestUpdateWebAdminInvalidClaims(t *testing.T) { assert.Contains(t, rr.Body.String(), "Invalid token claims") } +func TestCSRFToken(t *testing.T) { + // invalid token + err := verifyCSRFToken("token") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "Unable to verify form token") + } + // bad audience + claims := make(map[string]interface{}) + now := time.Now().UTC() + + claims[jwt.JwtIDKey] = xid.New().String() + claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) + claims[jwt.ExpirationKey] = now.Add(tokenDuration) + claims[jwt.AudienceKey] = tokenAudienceAPI + + _, tokenString, err := csrfTokenAuth.Encode(claims) + assert.NoError(t, err) + err = verifyCSRFToken(tokenString) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "form token is not valid") + } + + r := GetHTTPRouter() + fn := verifyCSRFHeader(r) + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, path.Join(userPath, "username"), nil) + fn.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token") + + req.Header.Set(csrfHeaderToken, tokenString) + rr = httptest.NewRecorder() + fn.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "The token is not valid") + + csrfTokenAuth = jwtauth.New("PS256", utils.GenerateRandomBytes(32), nil) + tokenString = createCSRFToken() + assert.Empty(t, tokenString) + + csrfTokenAuth = jwtauth.New("HS256", utils.GenerateRandomBytes(32), nil) +} + func TestCreateTokenError(t *testing.T) { server := httpdServer{ tokenAuth: jwtauth.New("PS256", utils.GenerateRandomBytes(32), nil), @@ -386,6 +431,7 @@ func TestCreateTokenError(t *testing.T) { form := make(url.Values) form.Set("username", admin.Username) form.Set("password", admin.Password) + form.Set(csrfFormToken, createCSRFToken()) req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -470,6 +516,7 @@ func TestAdminAllowListConnAddr(t *testing.T) { req.RemoteAddr = "192.168.1.16:1234" server.checkAddrAndSendToken(rr, req.WithContext(ctx), admin) assert.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String()) + assert.Equal(t, "context value connection address", connAddrKey.String()) } func TestUpdateContextFromCookie(t *testing.T) { diff --git a/httpd/middleware.go b/httpd/middleware.go index b4f14569..5454cc8b 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -2,6 +2,7 @@ package httpd import ( "context" + "errors" "net/http" "github.com/go-chi/jwtauth" @@ -11,9 +12,15 @@ import ( "github.com/drakkan/sftpgo/utils" ) -type ctxKeyConnAddr int +var connAddrKey = &contextKey{"connection address"} -const connAddrKey ctxKeyConnAddr = 0 +type contextKey struct { + name string +} + +func (k *contextKey) String() string { + return "context value " + k.name +} func saveConnectionAddress(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -26,14 +33,14 @@ func jwtAuthenticator(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, _, err := jwtauth.FromContext(r.Context()) - if err != nil { + if err != nil || token == nil { logger.Debug(logSender, "", "error getting jwt token: %v", err) sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } err = jwt.Validate(token) - if token == nil || err != nil { + if err != nil { logger.Debug(logSender, "", "error validating jwt token: %v", err) sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return @@ -58,14 +65,14 @@ func jwtAuthenticatorWeb(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, _, err := jwtauth.FromContext(r.Context()) - if err != nil { + if err != nil || token == nil { logger.Debug(logSender, "", "error getting web jwt token: %v", err) http.Redirect(w, r, webLoginPath, http.StatusFound) return } err = jwt.Validate(token) - if token == nil || err != nil { + if err != nil { logger.Debug(logSender, "", "error validating web jwt token: %v", err) http.Redirect(w, r, webLoginPath, http.StatusFound) return @@ -114,3 +121,23 @@ func checkPerm(perm string) func(next http.Handler) http.Handler { }) } } + +func verifyCSRFHeader(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenString := r.Header.Get(csrfHeaderToken) + token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString) + if err != nil || token == nil { + logger.Debug(logSender, "", "error validating CSRF header: %v", err) + sendAPIResponse(w, r, err, "Invalid token", http.StatusForbidden) + return + } + + if !utils.IsStringInSlice(tokenAudienceCSRF, token.Audience()) { + logger.Debug(logSender, "", "error validating CSRF header audience") + sendAPIResponse(w, r, errors.New("The token is not valid"), "", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/httpd/server.go b/httpd/server.go index 0851ba7e..0685bf7c 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -109,6 +109,10 @@ func (s *httpdServer) handleWebLoginPost(w http.ResponseWriter, r *http.Request) renderLoginPage(w, "Invalid credentials") return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderLoginPage(w, err.Error()) + return + } admin, err := dataprovider.CheckAdminAndPass(username, password, utils.GetIPFromRemoteAddress(r.RemoteAddr)) if err != nil { renderLoginPage(w, err.Error()) @@ -367,16 +371,21 @@ func (s *httpdServer) initializeRouter() { Get(webAdminPath+"/{username}", handleWebUpdateAdminGet) router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Post(webAdminPath, handleWebAddAdminPost) router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Post(webAdminPath+"/{username}", handleWebUpdateAdminPost) - router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Delete(webAdminPath+"/{username}", deleteAdmin) - router.With(checkPerm(dataprovider.PermAdminCloseConnections)). + router.With(checkPerm(dataprovider.PermAdminManageAdmins), verifyCSRFHeader). + Delete(webAdminPath+"/{username}", deleteAdmin) + router.With(checkPerm(dataprovider.PermAdminCloseConnections), verifyCSRFHeader). Delete(webConnectionsPath+"/{connectionID}", handleCloseConnection) router.With(checkPerm(dataprovider.PermAdminChangeUsers), s.refreshCookie). Get(webFolderPath+"/{name}", handleWebUpdateFolderGet) router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Post(webFolderPath+"/{name}", handleWebUpdateFolderPost) - router.With(checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(webFolderPath+"/{name}", deleteFolder) - router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(webScanVFolderPath, startVFolderQuotaScan) - router.With(checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(webUserPath+"/{username}", deleteUser) - router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(webQuotaScanPath, startQuotaScan) + router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader). + Delete(webFolderPath+"/{name}", deleteFolder) + router.With(checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader). + Post(webScanVFolderPath, startVFolderQuotaScan) + router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader). + Delete(webUserPath+"/{username}", deleteUser) + router.With(checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader). + Post(webQuotaScanPath, startQuotaScan) router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance) router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData) router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore) diff --git a/httpd/web.go b/httpd/web.go index 2ff43d51..1eda766d 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -68,6 +68,8 @@ const ( defaultQueryLimit = 500 webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS redactedSecret = "[**redacted**]" + csrfFormToken = "_form_token" + csrfHeaderToken = "X-CSRF-TOKEN" ) var ( @@ -98,6 +100,7 @@ type basePage struct { StatusTitle string MaintenanceTitle string Version string + CSRFToken string LoggedAdmin *dataprovider.Admin } @@ -175,6 +178,7 @@ type loginPage struct { CurrentURL string Version string Error string + CSRFToken string } type userTemplateFields struct { @@ -259,6 +263,10 @@ func loadTemplates(templatesPath string) { } func getBasePageData(title, currentURL string, r *http.Request) basePage { + var csrfToken string + if currentURL != "" { + csrfToken = createCSRFToken() + } return basePage{ Title: title, CurrentURL: currentURL, @@ -284,6 +292,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage { MaintenanceTitle: pageMaintenanceTitle, Version: version.GetAsString(), LoggedAdmin: getAdminFromToken(r), + CSRFToken: csrfToken, } } @@ -946,6 +955,7 @@ func renderLoginPage(w http.ResponseWriter, error string) { CurrentURL: webLoginPath, Version: version.Get().Version, Error: error, + CSRFToken: createCSRFToken(), } renderTemplate(w, templateLogin, data) } @@ -961,6 +971,10 @@ func handleWebAdminChangePwdPost(w http.ResponseWriter, r *http.Request) { renderChangePwdPage(w, r, err.Error()) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } err = doChangeAdminPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"), r.Form.Get("new_password2")) if err != nil { @@ -991,6 +1005,10 @@ func handleWebRestore(w http.ResponseWriter, r *http.Request) { renderMaintenancePage(w, r, err.Error()) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } restoreMode, err := strconv.Atoi(r.Form.Get("mode")) if err != nil { renderMaintenancePage(w, r, err.Error()) @@ -1077,6 +1095,10 @@ func handleWebAddAdminPost(w http.ResponseWriter, r *http.Request) { renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } err = dataprovider.AddAdmin(&admin) if err != nil { renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) @@ -1103,6 +1125,10 @@ func handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Request) { renderAddUpdateAdminPage(w, r, &updatedAdmin, err.Error(), false) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } updatedAdmin.ID = admin.ID updatedAdmin.Username = admin.Username if updatedAdmin.Password == "" { @@ -1184,6 +1210,10 @@ func handleWebTemplateUserPost(w http.ResponseWriter, r *http.Request) { renderMessagePage(w, r, "Error parsing user fields", "", http.StatusBadRequest, err, "") return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } var dump dataprovider.BackupData dump.Version = dataprovider.DumpVersion @@ -1251,6 +1281,10 @@ func handleWebAddUserPost(w http.ResponseWriter, r *http.Request) { renderUserPage(w, r, &user, userPageModeAdd, err.Error()) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } err = dataprovider.AddUser(&user) if err == nil { http.Redirect(w, r, webUsersPath, http.StatusSeeOther) @@ -1275,6 +1309,10 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) { renderUserPage(w, r, &user, userPageModeUpdate, err.Error()) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } updatedUser.ID = user.ID updatedUser.Username = user.Username updatedUser.SetEmptySecretsIfNil() @@ -1325,6 +1363,10 @@ func handleWebAddFolderPost(w http.ResponseWriter, r *http.Request) { renderFolderPage(w, r, folder, folderPageModeAdd, err.Error()) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } folder.MappedPath = r.Form.Get("mapped_path") folder.Name = r.Form.Get("name") @@ -1365,6 +1407,10 @@ func handleWebUpdateFolderPost(w http.ResponseWriter, r *http.Request) { renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error()) return } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } folder.MappedPath = r.Form.Get("mapped_path") err = dataprovider.UpdateFolder(&folder) if err != nil { diff --git a/templates/admin.html b/templates/admin.html index 16b7b3e9..4b74f072 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -88,6 +88,7 @@ + diff --git a/templates/admins.html b/templates/admins.html index 85bb3c55..bed10fa5 100644 --- a/templates/admins.html +++ b/templates/admins.html @@ -99,6 +99,7 @@ url: path, type: 'DELETE', dataType: 'json', + headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, timeout: 15000, success: function (result) { table.button('delete:name').enable(true); diff --git a/templates/changepwd.html b/templates/changepwd.html index 6ce86157..72deeee1 100644 --- a/templates/changepwd.html +++ b/templates/changepwd.html @@ -36,6 +36,7 @@ + diff --git a/templates/connections.html b/templates/connections.html index e393c7e1..cf476543 100644 --- a/templates/connections.html +++ b/templates/connections.html @@ -98,6 +98,7 @@ url: path, type: 'DELETE', dataType: 'json', + headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, timeout: 15000, success: function (result) { setTimeout(function () { diff --git a/templates/folder.html b/templates/folder.html index 68980d64..0ef97ed8 100644 --- a/templates/folder.html +++ b/templates/folder.html @@ -29,6 +29,7 @@ + diff --git a/templates/folders.html b/templates/folders.html index 4477fd49..aa2a7c34 100644 --- a/templates/folders.html +++ b/templates/folders.html @@ -97,6 +97,7 @@ function deleteAction() { url: encodeURI(path), type: 'DELETE', dataType: 'json', + headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, timeout: 15000, success: function (result) { table.button('delete:name').enable(true); @@ -160,6 +161,7 @@ function deleteAction() { url: path, type: 'POST', dataType: 'json', + headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, data: JSON.stringify({ "name": folderName }), timeout: 15000, success: function (result) { diff --git a/templates/login.html b/templates/login.html index 9b1f9832..23a69e80 100644 --- a/templates/login.html +++ b/templates/login.html @@ -80,7 +80,7 @@ - + diff --git a/templates/maintenance.html b/templates/maintenance.html index 39d0556b..ca903d39 100644 --- a/templates/maintenance.html +++ b/templates/maintenance.html @@ -44,6 +44,7 @@ + diff --git a/templates/user.html b/templates/user.html index 2f3a31f4..31309c2e 100644 --- a/templates/user.html +++ b/templates/user.html @@ -583,7 +583,10 @@
+ value="{{.User.FsConfig.SFTPConfig.Endpoint}}" maxlength="255" aria-describedby="SFTPEndpointHelpBlock"> + + Endpoint as host:port, port is always required +
@@ -659,6 +662,7 @@ {{end}} + diff --git a/templates/users.html b/templates/users.html index 91bc0af9..d2298e8e 100644 --- a/templates/users.html +++ b/templates/users.html @@ -105,6 +105,7 @@ url: encodeURI(path), type: 'DELETE', dataType: 'json', + headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, timeout: 15000, success: function (result) { table.button('delete:name').enable(true); @@ -199,6 +200,7 @@ url: path, type: 'POST', dataType: 'json', + headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, data: JSON.stringify({ "username": username }), timeout: 15000, success: function (result) {