[MM-10346] CSRF Token Implementation + Tests (#10067)
* CSRF Token Implementation + Tests Remove debug statements Implement requested changes * Fix non-cookie authentication methods stripping auth data from requests * Fail when CSRF cookie is not returned as part of login
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
86aa01cf36
Коммит
7cc66ee1d4
@@ -6,6 +6,7 @@ package api4
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,7 +28,22 @@ func TestCreateUser(t *testing.T) {
|
|||||||
CheckNoError(t, resp)
|
CheckNoError(t, resp)
|
||||||
CheckCreatedStatus(t, resp)
|
CheckCreatedStatus(t, resp)
|
||||||
|
|
||||||
th.Client.Login(user.Email, user.Password)
|
_, resp = th.Client.Login(user.Email, user.Password)
|
||||||
|
session, _ := th.App.GetSession(th.Client.AuthToken)
|
||||||
|
expectedCsrf := "MMCSRF=" + session.GetCSRF()
|
||||||
|
actualCsrf := ""
|
||||||
|
|
||||||
|
for _, cookie := range resp.Header["Set-Cookie"] {
|
||||||
|
if strings.HasPrefix(cookie, "MMCSRF") {
|
||||||
|
cookieParts := strings.Split(cookie, ";")
|
||||||
|
actualCsrf = cookieParts[0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedCsrf != actualCsrf {
|
||||||
|
t.Errorf("CSRF Mismatch - Expected %s, got %s", expectedCsrf, actualCsrf)
|
||||||
|
}
|
||||||
|
|
||||||
if ruser.Nickname != user.Nickname {
|
if ruser.Nickname != user.Nickname {
|
||||||
t.Fatal("nickname didn't match")
|
t.Fatal("nickname didn't match")
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ func (a *App) trackConfig() {
|
|||||||
"allow_cookies_for_subdomains": *cfg.ServiceSettings.AllowCookiesForSubdomains,
|
"allow_cookies_for_subdomains": *cfg.ServiceSettings.AllowCookiesForSubdomains,
|
||||||
"enable_api_team_deletion": *cfg.ServiceSettings.EnableAPITeamDeletion,
|
"enable_api_team_deletion": *cfg.ServiceSettings.EnableAPITeamDeletion,
|
||||||
"experimental_enable_hardened_mode": *cfg.ServiceSettings.ExperimentalEnableHardenedMode,
|
"experimental_enable_hardened_mode": *cfg.ServiceSettings.ExperimentalEnableHardenedMode,
|
||||||
|
"experimental_strict_csrf_enforcement": *cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement,
|
||||||
"enable_email_invitations": *cfg.ServiceSettings.EnableEmailInvitations,
|
"enable_email_invitations": *cfg.ServiceSettings.EnableEmailInvitations,
|
||||||
"experimental_channel_organization": *cfg.ServiceSettings.ExperimentalChannelOrganization,
|
"experimental_channel_organization": *cfg.ServiceSettings.ExperimentalChannelOrganization,
|
||||||
"experimental_ldap_group_sync": *cfg.ServiceSettings.ExperimentalLdapGroupSync,
|
"experimental_ldap_group_sync": *cfg.ServiceSettings.ExperimentalLdapGroupSync,
|
||||||
|
|||||||
11
app/login.go
11
app/login.go
@@ -189,8 +189,19 @@ func (a *App) DoLogin(w http.ResponseWriter, r *http.Request, user *model.User,
|
|||||||
Secure: secure,
|
Secure: secure,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
csrfCookie := &http.Cookie{
|
||||||
|
Name: model.SESSION_COOKIE_CSRF,
|
||||||
|
Value: session.GetCSRF(),
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Expires: expiresAt,
|
||||||
|
Domain: domain,
|
||||||
|
Secure: secure,
|
||||||
|
}
|
||||||
|
|
||||||
http.SetCookie(w, sessionCookie)
|
http.SetCookie(w, sessionCookie)
|
||||||
http.SetCookie(w, userCookie)
|
http.SetCookie(w, userCookie)
|
||||||
|
http.SetCookie(w, csrfCookie)
|
||||||
|
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,21 +65,34 @@ func (a *App) servePluginRequest(w http.ResponseWriter, r *http.Request, handler
|
|||||||
r.Header.Del("Mattermost-User-Id")
|
r.Header.Del("Mattermost-User-Id")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
session, err := a.GetSession(token)
|
session, err := a.GetSession(token)
|
||||||
csrfCheckPassed := true
|
csrfCheckPassed := false
|
||||||
|
|
||||||
if err == nil && cookieAuth && r.Method != "GET" && r.Header.Get(model.HEADER_REQUESTED_WITH) != model.HEADER_REQUESTED_WITH_XML {
|
if err == nil && cookieAuth && r.Method != "GET" {
|
||||||
bodyBytes, _ := ioutil.ReadAll(r.Body)
|
sentToken := ""
|
||||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
||||||
r.ParseForm()
|
|
||||||
sentToken := r.FormValue("csrf")
|
|
||||||
expectedToken := session.GetCSRF()
|
|
||||||
|
|
||||||
if sentToken != expectedToken {
|
if r.Header.Get(model.HEADER_CSRF_TOKEN) == "" {
|
||||||
csrfCheckPassed = false
|
bodyBytes, _ := ioutil.ReadAll(r.Body)
|
||||||
|
r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||||
|
r.ParseForm()
|
||||||
|
sentToken = r.FormValue("csrf")
|
||||||
|
r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||||
|
} else {
|
||||||
|
sentToken = r.Header.Get(model.HEADER_CSRF_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Request Body again, since otherwise form values aren't accessible in plugin handler
|
expectedToken := session.GetCSRF()
|
||||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
||||||
|
if sentToken == expectedToken {
|
||||||
|
csrfCheckPassed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToDo(DSchalla) 2019/01/04: Remove after deprecation period and only allow CSRF Header (MM-13657)
|
||||||
|
if !*a.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement && r.Header.Get(model.HEADER_REQUESTED_WITH) == model.HEADER_REQUESTED_WITH_XML && !csrfCheckPassed {
|
||||||
|
a.Log.Warn("CSRF Check failed for request - Please migrate your plugin to either send a CSRF Header or Form Field, XMLHttpRequest is deprecated")
|
||||||
|
csrfCheckPassed = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
csrfCheckPassed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if session != nil && err == nil && csrfCheckPassed {
|
if session != nil && err == nil && csrfCheckPassed {
|
||||||
|
|||||||
@@ -74,7 +74,8 @@
|
|||||||
"EnableAPITeamDeletion": false,
|
"EnableAPITeamDeletion": false,
|
||||||
"ExperimentalEnableHardenedMode": false,
|
"ExperimentalEnableHardenedMode": false,
|
||||||
"EnableEmailInvitations": false,
|
"EnableEmailInvitations": false,
|
||||||
"ExperimentalLdapGroupSync": false
|
"ExperimentalLdapGroupSync": false,
|
||||||
|
"ExperimentalStrictCSRFEnforcement": false
|
||||||
},
|
},
|
||||||
"TeamSettings": {
|
"TeamSettings": {
|
||||||
"SiteName": "Mattermost",
|
"SiteName": "Mattermost",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const (
|
|||||||
HEADER_REAL_IP = "X-Real-IP"
|
HEADER_REAL_IP = "X-Real-IP"
|
||||||
HEADER_FORWARDED_PROTO = "X-Forwarded-Proto"
|
HEADER_FORWARDED_PROTO = "X-Forwarded-Proto"
|
||||||
HEADER_TOKEN = "token"
|
HEADER_TOKEN = "token"
|
||||||
|
HEADER_CSRF_TOKEN = "X-CSRF-Token"
|
||||||
HEADER_BEARER = "BEARER"
|
HEADER_BEARER = "BEARER"
|
||||||
HEADER_AUTH = "Authorization"
|
HEADER_AUTH = "Authorization"
|
||||||
HEADER_REQUESTED_WITH = "X-Requested-With"
|
HEADER_REQUESTED_WITH = "X-Requested-With"
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ type ServiceSettings struct {
|
|||||||
DEPRECATED_DO_NOT_USE_ImageProxyOptions *string `json:"ImageProxyOptions"` // This field is deprecated and must not be used.
|
DEPRECATED_DO_NOT_USE_ImageProxyOptions *string `json:"ImageProxyOptions"` // This field is deprecated and must not be used.
|
||||||
EnableAPITeamDeletion *bool
|
EnableAPITeamDeletion *bool
|
||||||
ExperimentalEnableHardenedMode *bool
|
ExperimentalEnableHardenedMode *bool
|
||||||
|
ExperimentalStrictCSRFEnforcement *bool
|
||||||
EnableEmailInvitations *bool
|
EnableEmailInvitations *bool
|
||||||
ExperimentalLdapGroupSync *bool
|
ExperimentalLdapGroupSync *bool
|
||||||
}
|
}
|
||||||
@@ -611,6 +612,10 @@ func (s *ServiceSettings) SetDefaults() {
|
|||||||
if s.ExperimentalLdapGroupSync == nil {
|
if s.ExperimentalLdapGroupSync == nil {
|
||||||
s.ExperimentalLdapGroupSync = NewBool(false)
|
s.ExperimentalLdapGroupSync = NewBool(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.ExperimentalStrictCSRFEnforcement == nil {
|
||||||
|
s.ExperimentalStrictCSRFEnforcement = NewBool(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClusterSettings struct {
|
type ClusterSettings struct {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
SESSION_COOKIE_TOKEN = "MMAUTHTOKEN"
|
SESSION_COOKIE_TOKEN = "MMAUTHTOKEN"
|
||||||
SESSION_COOKIE_USER = "MMUSERID"
|
SESSION_COOKIE_USER = "MMUSERID"
|
||||||
|
SESSION_COOKIE_CSRF = "MMCSRF"
|
||||||
SESSION_CACHE_SIZE = 35000
|
SESSION_CACHE_SIZE = 35000
|
||||||
SESSION_PROP_PLATFORM = "platform"
|
SESSION_PROP_PLATFORM = "platform"
|
||||||
SESSION_PROP_OS = "os"
|
SESSION_PROP_OS = "os"
|
||||||
|
|||||||
@@ -62,16 +62,6 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
c.App.Path = r.URL.Path
|
c.App.Path = r.URL.Path
|
||||||
c.Log = c.App.Log
|
c.Log = c.App.Log
|
||||||
|
|
||||||
token, tokenLocation := app.ParseAuthTokenFromRequest(r)
|
|
||||||
|
|
||||||
// CSRF Check
|
|
||||||
if tokenLocation == app.TokenLocationCookie && h.RequireSession && !h.TrustRequester {
|
|
||||||
if r.Header.Get(model.HEADER_REQUESTED_WITH) != model.HEADER_REQUESTED_WITH_XML {
|
|
||||||
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token+" Appears to be a CSRF attempt", http.StatusUnauthorized)
|
|
||||||
token = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subpath, _ := utils.GetSubpathFromConfig(c.App.Config())
|
subpath, _ := utils.GetSubpathFromConfig(c.App.Config())
|
||||||
siteURLHeader := app.GetProtocol(r) + "://" + r.Host + subpath
|
siteURLHeader := app.GetProtocol(r) + "://" + r.Host + subpath
|
||||||
c.SetSiteURLHeader(siteURLHeader)
|
c.SetSiteURLHeader(siteURLHeader)
|
||||||
@@ -97,26 +87,51 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token, tokenLocation := app.ParseAuthTokenFromRequest(r)
|
||||||
|
|
||||||
if len(token) != 0 {
|
if len(token) != 0 {
|
||||||
session, err := c.App.GetSession(token)
|
session, err := c.App.GetSession(token)
|
||||||
|
csrfCheckPassed := false
|
||||||
|
|
||||||
if err != nil {
|
// CSRF Check
|
||||||
c.Log.Info("Invalid session", mlog.Err(err))
|
if tokenLocation == app.TokenLocationCookie && h.RequireSession && !h.TrustRequester && r.Method != "GET" {
|
||||||
if err.StatusCode == http.StatusInternalServerError {
|
csrfHeader := r.Header.Get(model.HEADER_CSRF_TOKEN)
|
||||||
c.Err = err
|
if csrfHeader == session.GetCSRF() {
|
||||||
} else if h.RequireSession {
|
csrfCheckPassed = true
|
||||||
c.RemoveSessionCookie(w, r)
|
} else if !*c.App.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement && r.Header.Get(model.HEADER_REQUESTED_WITH) == model.HEADER_REQUESTED_WITH_XML {
|
||||||
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized)
|
// ToDo(DSchalla) 2019/01/04: Remove after deprecation period and only allow CSRF Header (MM-13657)
|
||||||
|
c.Log.Warn("CSRF Header check failed for request - Please upgrade your web application or custom app to set a CSRF Header")
|
||||||
|
csrfCheckPassed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !csrfCheckPassed {
|
||||||
|
token = ""
|
||||||
|
session = nil
|
||||||
|
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token+" Appears to be a CSRF attempt", http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
} else if !session.IsOAuth && tokenLocation == app.TokenLocationQueryString {
|
|
||||||
c.Err = model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token="+token, http.StatusUnauthorized)
|
|
||||||
} else {
|
} else {
|
||||||
c.App.Session = *session
|
csrfCheckPassed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit by UserID
|
if csrfCheckPassed {
|
||||||
if c.App.Srv.RateLimiter != nil && c.App.Srv.RateLimiter.UserIdRateLimit(c.App.Session.UserId, w) {
|
if err != nil {
|
||||||
return
|
c.Log.Info("Invalid session", mlog.Err(err))
|
||||||
|
if err.StatusCode == http.StatusInternalServerError {
|
||||||
|
c.Err = err
|
||||||
|
} else if h.RequireSession {
|
||||||
|
c.RemoveSessionCookie(w, r)
|
||||||
|
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
} else if !session.IsOAuth && tokenLocation == app.TokenLocationQueryString {
|
||||||
|
c.Err = model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token="+token, http.StatusUnauthorized)
|
||||||
|
} else {
|
||||||
|
c.App.Session = *session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit by UserID
|
||||||
|
if c.App.Srv.RateLimiter != nil && c.App.Srv.RateLimiter.UserIdRateLimit(c.App.Session.UserId, w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,3 +108,105 @@ func TestHandlerServeHTTPSecureTransport(t *testing.T) {
|
|||||||
t.Errorf("Strict-Transport-Security header is not expected, but returned")
|
t.Errorf("Strict-Transport-Security header is not expected, but returned")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func handlerForCSRFToken(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerServeCSRFToken(t *testing.T) {
|
||||||
|
th := Setup().InitBasic()
|
||||||
|
defer th.TearDown()
|
||||||
|
|
||||||
|
session :=&model.Session{
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
CreateAt: model.GetMillis(),
|
||||||
|
Roles: model.SYSTEM_USER_ROLE_ID,
|
||||||
|
IsOAuth: false,
|
||||||
|
}
|
||||||
|
session.GenerateCSRF()
|
||||||
|
session.SetExpireInDays(1)
|
||||||
|
session, err := th.App.CreateSession(session)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil, got %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
web := New(th.Server, th.Server.AppOptions, th.Server.Router)
|
||||||
|
|
||||||
|
handler := Handler{
|
||||||
|
GetGlobalAppOptions: web.GetGlobalAppOptions,
|
||||||
|
HandleFunc: handlerForCSRFToken,
|
||||||
|
RequireSession: true,
|
||||||
|
TrustRequester: false,
|
||||||
|
RequireMfa: false,
|
||||||
|
IsStatic: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: model.SESSION_COOKIE_USER,
|
||||||
|
Value: th.BasicUser.Username,
|
||||||
|
}
|
||||||
|
cookie2 := &http.Cookie{
|
||||||
|
Name: model.SESSION_COOKIE_TOKEN,
|
||||||
|
Value: session.Token,
|
||||||
|
}
|
||||||
|
cookie3 := &http.Cookie{
|
||||||
|
Name: model.SESSION_COOKIE_CSRF,
|
||||||
|
Value: session.GetCSRF(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Token Used - Success Expected
|
||||||
|
|
||||||
|
request := httptest.NewRequest("POST", "/api/v4/test", nil)
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
request.AddCookie(cookie2)
|
||||||
|
request.AddCookie(cookie3)
|
||||||
|
request.Header.Add(model.HEADER_CSRF_TOKEN, session.GetCSRF())
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != 200 {
|
||||||
|
t.Errorf("Expected status 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No CSRF Token Used - Failure Expected
|
||||||
|
|
||||||
|
request = httptest.NewRequest("POST", "/api/v4/test", nil)
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
request.AddCookie(cookie2)
|
||||||
|
request.AddCookie(cookie3)
|
||||||
|
response = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != 401 {
|
||||||
|
t.Errorf("Expected status 401, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback Behavior Used - Success expected
|
||||||
|
// ToDo (DSchalla) 2019/01/04: Remove once legacy CSRF Handling is removed
|
||||||
|
th.App.UpdateConfig(func(config *model.Config){
|
||||||
|
*config.ServiceSettings.ExperimentalStrictCSRFEnforcement = false
|
||||||
|
})
|
||||||
|
request = httptest.NewRequest("POST", "/api/v4/test", nil)
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
request.AddCookie(cookie2)
|
||||||
|
request.AddCookie(cookie3)
|
||||||
|
request.Header.Add(model.HEADER_REQUESTED_WITH, model.HEADER_REQUESTED_WITH_XML)
|
||||||
|
response = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != 200 {
|
||||||
|
t.Errorf("Expected status 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback Behavior Used with Strict Enforcement - Failure Expected
|
||||||
|
// ToDo (DSchalla) 2019/01/04: Remove once legacy CSRF Handling is removed
|
||||||
|
th.App.UpdateConfig(func(config *model.Config){
|
||||||
|
*config.ServiceSettings.ExperimentalStrictCSRFEnforcement = true
|
||||||
|
})
|
||||||
|
response = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != 401 {
|
||||||
|
t.Errorf("Expected status 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user