Commit c5f906f4 by Jeffrey Descan Committed by Sofia Papagiannaki

Security: refactor 'redirect_to' cookie to use 'Secure' flag (#19787)

* Refactor redirect_to cookie with secure flag in middleware

* Refactor redirect_to cookie with secure flag in api/login

* Refactor redirect_to cookie with secure flag in api/login_oauth

* Removed the deletion of 'Set-Cookie' header to prevent logout

* Removed the deletion of 'Set-Cookie' at top of api/login.go

* Add HttpOnly flag on redirect_to cookies where missing

* Refactor duplicated code

* Add tests

* Refactor cookie options

* Replace local function for deleting cookie

* Delete redundant calls

Co-authored-by: Sofia Papagiannaki <papagian@users.noreply.github.com>
parent a3c99f48
...@@ -2,7 +2,6 @@ package api ...@@ -2,7 +2,6 @@ package api
import ( import (
"encoding/hex" "encoding/hex"
"net/http"
"net/url" "net/url"
"strings" "strings"
...@@ -28,7 +27,7 @@ var getViewIndex = func() string { ...@@ -28,7 +27,7 @@ var getViewIndex = func() string {
return ViewIndex return ViewIndex
} }
func validateRedirectTo(redirectTo string) error { func (hs *HTTPServer) validateRedirectTo(redirectTo string) error {
to, err := url.Parse(redirectTo) to, err := url.Parse(redirectTo)
if err != nil { if err != nil {
return login.ErrInvalidRedirectTo return login.ErrInvalidRedirectTo
...@@ -36,12 +35,20 @@ func validateRedirectTo(redirectTo string) error { ...@@ -36,12 +35,20 @@ func validateRedirectTo(redirectTo string) error {
if to.IsAbs() { if to.IsAbs() {
return login.ErrAbsoluteRedirectTo return login.ErrAbsoluteRedirectTo
} }
if setting.AppSubUrl != "" && !strings.HasPrefix(to.Path, "/"+setting.AppSubUrl) { if hs.Cfg.AppSubUrl != "" && !strings.HasPrefix(to.Path, "/"+hs.Cfg.AppSubUrl) {
return login.ErrInvalidRedirectTo return login.ErrInvalidRedirectTo
} }
return nil return nil
} }
func (hs *HTTPServer) cookieOptionsFromCfg() middleware.CookieOptions {
return middleware.CookieOptions{
Path: hs.Cfg.AppSubUrl + "/",
Secure: hs.Cfg.CookieSecure,
SameSite: hs.Cfg.CookieSameSite,
}
}
func (hs *HTTPServer) LoginView(c *models.ReqContext) { func (hs *HTTPServer) LoginView(c *models.ReqContext) {
viewData, err := setIndexViewData(hs, c) viewData, err := setIndexViewData(hs, c)
if err != nil { if err != nil {
...@@ -62,7 +69,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { ...@@ -62,7 +69,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) {
//therefore the loginError should be passed to the view data //therefore the loginError should be passed to the view data
//and the view should return immediately before attempting //and the view should return immediately before attempting
//to login again via OAuth and enter to a redirect loop //to login again via OAuth and enter to a redirect loop
deleteCookie(c, LoginErrorCookieName) middleware.DeleteCookie(c.Resp, LoginErrorCookieName, hs.cookieOptionsFromCfg)
viewData.Settings["loginError"] = loginError viewData.Settings["loginError"] = loginError
c.HTML(200, getViewIndex(), viewData) c.HTML(200, getViewIndex(), viewData)
return return
...@@ -79,13 +86,13 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { ...@@ -79,13 +86,13 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) {
} }
if redirectTo, _ := url.QueryUnescape(c.GetCookie("redirect_to")); len(redirectTo) > 0 { if redirectTo, _ := url.QueryUnescape(c.GetCookie("redirect_to")); len(redirectTo) > 0 {
if err := validateRedirectTo(redirectTo); err != nil { if err := hs.validateRedirectTo(redirectTo); err != nil {
viewData.Settings["loginError"] = err.Error() viewData.Settings["loginError"] = err.Error()
c.HTML(200, getViewIndex(), viewData) c.HTML(200, getViewIndex(), viewData)
c.SetCookie("redirect_to", "", -1, setting.AppSubUrl+"/") middleware.DeleteCookie(c.Resp, "redirect_to", hs.cookieOptionsFromCfg)
return return
} }
c.SetCookie("redirect_to", "", -1, setting.AppSubUrl+"/") middleware.DeleteCookie(c.Resp, "redirect_to", hs.cookieOptionsFromCfg)
c.Redirect(redirectTo) c.Redirect(redirectTo)
return return
} }
...@@ -168,12 +175,12 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext, cmd dtos.LoginCommand) Res ...@@ -168,12 +175,12 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext, cmd dtos.LoginCommand) Res
} }
if redirectTo, _ := url.QueryUnescape(c.GetCookie("redirect_to")); len(redirectTo) > 0 { if redirectTo, _ := url.QueryUnescape(c.GetCookie("redirect_to")); len(redirectTo) > 0 {
if err := validateRedirectTo(redirectTo); err == nil { if err := hs.validateRedirectTo(redirectTo); err == nil {
result["redirectUrl"] = redirectTo result["redirectUrl"] = redirectTo
} else { } else {
log.Info("Ignored invalid redirect_to cookie value: %v", redirectTo) log.Info("Ignored invalid redirect_to cookie value: %v", redirectTo)
} }
c.SetCookie("redirect_to", "", -1, setting.AppSubUrl+"/") middleware.DeleteCookie(c.Resp, "redirect_to", hs.cookieOptionsFromCfg)
} }
metrics.MApiLoginPost.Inc() metrics.MApiLoginPost.Inc()
...@@ -223,28 +230,13 @@ func tryGetEncryptedCookie(ctx *models.ReqContext, cookieName string) (string, b ...@@ -223,28 +230,13 @@ func tryGetEncryptedCookie(ctx *models.ReqContext, cookieName string) (string, b
return string(decryptedError), err == nil return string(decryptedError), err == nil
} }
func deleteCookie(ctx *models.ReqContext, cookieName string) {
ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/")
}
func (hs *HTTPServer) trySetEncryptedCookie(ctx *models.ReqContext, cookieName string, value string, maxAge int) error { func (hs *HTTPServer) trySetEncryptedCookie(ctx *models.ReqContext, cookieName string, value string, maxAge int) error {
encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey) encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey)
if err != nil { if err != nil {
return err return err
} }
cookie := http.Cookie{ middleware.WriteCookie(ctx.Resp, cookieName, hex.EncodeToString(encryptedError), 60, hs.cookieOptionsFromCfg)
Name: cookieName,
MaxAge: 60,
Value: hex.EncodeToString(encryptedError),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.CookieSecure,
}
if hs.Cfg.CookieSameSite != http.SameSiteDefaultMode {
cookie.SameSite = hs.Cfg.CookieSameSite
}
http.SetCookie(ctx.Resp, &cookie)
return nil return nil
} }
...@@ -20,6 +20,7 @@ import ( ...@@ -20,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
...@@ -69,7 +70,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { ...@@ -69,7 +70,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
} }
hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret) hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60, hs.Cfg.CookieSameSite) middleware.WriteCookie(ctx.Resp, OauthStateCookieName, hashedState, 60, hs.cookieOptionsFromCfg)
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" { if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline)) ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
} else { } else {
...@@ -81,8 +82,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { ...@@ -81,8 +82,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
cookieState := ctx.GetCookie(OauthStateCookieName) cookieState := ctx.GetCookie(OauthStateCookieName)
// delete cookie // delete cookie
ctx.Resp.Header().Del("Set-Cookie") middleware.DeleteCookie(ctx.Resp, OauthStateCookieName, hs.cookieOptionsFromCfg)
hs.deleteCookie(ctx.Resp, OauthStateCookieName, hs.Cfg.CookieSameSite)
if cookieState == "" { if cookieState == "" {
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
...@@ -217,7 +217,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { ...@@ -217,7 +217,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
metrics.MApiLoginOAuth.Inc() metrics.MApiLoginOAuth.Inc()
if redirectTo, _ := url.QueryUnescape(ctx.GetCookie("redirect_to")); len(redirectTo) > 0 { if redirectTo, _ := url.QueryUnescape(ctx.GetCookie("redirect_to")); len(redirectTo) > 0 {
ctx.SetCookie("redirect_to", "", -1, setting.AppSubUrl+"/") middleware.DeleteCookie(ctx.Resp, "redirect_to", hs.cookieOptionsFromCfg)
ctx.Redirect(redirectTo) ctx.Redirect(redirectTo)
return return
} }
...@@ -225,25 +225,6 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { ...@@ -225,25 +225,6 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
ctx.Redirect(setting.AppSubUrl + "/") ctx.Redirect(setting.AppSubUrl + "/")
} }
func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string, sameSite http.SameSite) {
hs.writeCookie(w, name, "", -1, sameSite)
}
func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int, sameSite http.SameSite) {
cookie := http.Cookie{
Name: name,
MaxAge: maxAge,
Value: value,
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.CookieSecure,
}
if sameSite != http.SameSiteDefaultMode {
cookie.SameSite = sameSite
}
http.SetCookie(w, &cookie)
}
func hashStatecode(code, seed string) string { func hashStatecode(code, seed string) string {
hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed)) hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed))
return hex.EncodeToString(hashBytes[:]) return hex.EncodeToString(hashBytes[:])
......
...@@ -3,6 +3,7 @@ package api ...@@ -3,6 +3,7 @@ package api
import ( import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
...@@ -134,6 +135,7 @@ func TestLoginViewRedirect(t *testing.T) { ...@@ -134,6 +135,7 @@ func TestLoginViewRedirect(t *testing.T) {
Cfg: setting.NewCfg(), Cfg: setting.NewCfg(),
License: models.OSSLicensingService{}, License: models.OSSLicensingService{},
} }
hs.Cfg.CookieSecure = true
sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) { sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) {
c.IsSignedIn = true c.IsSignedIn = true
...@@ -192,15 +194,15 @@ func TestLoginViewRedirect(t *testing.T) { ...@@ -192,15 +194,15 @@ func TestLoginViewRedirect(t *testing.T) {
} }
for _, c := range redirectCases { for _, c := range redirectCases {
setting.AppUrl = c.appURL hs.Cfg.AppUrl = c.appURL
setting.AppSubUrl = c.appSubURL hs.Cfg.AppSubUrl = c.appSubURL
t.Run(c.desc, func(t *testing.T) { t.Run(c.desc, func(t *testing.T) {
cookie := http.Cookie{ cookie := http.Cookie{
Name: "redirect_to", Name: "redirect_to",
MaxAge: 60, MaxAge: 60,
Value: c.url, Value: c.url,
HttpOnly: true, HttpOnly: true,
Path: setting.AppSubUrl + "/", Path: hs.Cfg.AppSubUrl + "/",
Secure: hs.Cfg.CookieSecure, Secure: hs.Cfg.CookieSecure,
SameSite: hs.Cfg.CookieSameSite, SameSite: hs.Cfg.CookieSameSite,
} }
...@@ -211,6 +213,19 @@ func TestLoginViewRedirect(t *testing.T) { ...@@ -211,6 +213,19 @@ func TestLoginViewRedirect(t *testing.T) {
location, ok := sc.resp.Header()["Location"] location, ok := sc.resp.Header()["Location"]
assert.True(t, ok) assert.True(t, ok)
assert.Equal(t, location[0], c.url) assert.Equal(t, location[0], c.url)
setCookie, ok := sc.resp.Header()["Set-Cookie"]
assert.True(t, ok, "Set-Cookie exists")
assert.Greater(t, len(setCookie), 0)
var redirectToCookieFound bool
expCookieValue := fmt.Sprintf("redirect_to=%v; Path=%v; Max-Age=60; HttpOnly; Secure", c.url, hs.Cfg.AppSubUrl+"/")
for _, cookieValue := range setCookie {
if cookieValue == expCookieValue {
redirectToCookieFound = true
break
}
}
assert.True(t, redirectToCookieFound)
} }
responseString, err := getBody(sc.resp) responseString, err := getBody(sc.resp)
...@@ -235,6 +250,7 @@ func TestLoginPostRedirect(t *testing.T) { ...@@ -235,6 +250,7 @@ func TestLoginPostRedirect(t *testing.T) {
License: models.OSSLicensingService{}, License: models.OSSLicensingService{},
AuthTokenService: auth.NewFakeUserAuthTokenService(), AuthTokenService: auth.NewFakeUserAuthTokenService(),
} }
hs.Cfg.CookieSecure = true
sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) Response { sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) Response {
cmd := dtos.LoginCommand{ cmd := dtos.LoginCommand{
...@@ -286,15 +302,15 @@ func TestLoginPostRedirect(t *testing.T) { ...@@ -286,15 +302,15 @@ func TestLoginPostRedirect(t *testing.T) {
} }
for _, c := range redirectCases { for _, c := range redirectCases {
setting.AppUrl = c.appURL hs.Cfg.AppUrl = c.appURL
setting.AppSubUrl = c.appSubURL hs.Cfg.AppSubUrl = c.appSubURL
t.Run(c.desc, func(t *testing.T) { t.Run(c.desc, func(t *testing.T) {
cookie := http.Cookie{ cookie := http.Cookie{
Name: "redirect_to", Name: "redirect_to",
MaxAge: 60, MaxAge: 60,
Value: c.url, Value: c.url,
HttpOnly: true, HttpOnly: true,
Path: setting.AppSubUrl + "/", Path: hs.Cfg.AppSubUrl + "/",
Secure: hs.Cfg.CookieSecure, Secure: hs.Cfg.CookieSecure,
SameSite: hs.Cfg.CookieSameSite, SameSite: hs.Cfg.CookieSameSite,
} }
...@@ -310,6 +326,19 @@ func TestLoginPostRedirect(t *testing.T) { ...@@ -310,6 +326,19 @@ func TestLoginPostRedirect(t *testing.T) {
} else { } else {
assert.Equal(t, c.url, redirectURL) assert.Equal(t, c.url, redirectURL)
} }
// assert redirect_to cookie is deleted
setCookie, ok := sc.resp.Header()["Set-Cookie"]
assert.True(t, ok, "Set-Cookie exists")
assert.Greater(t, len(setCookie), 0)
var redirectToCookieFound bool
expCookieValue := fmt.Sprintf("redirect_to=; Path=%v; Max-Age=0; HttpOnly; Secure", hs.Cfg.AppSubUrl+"/")
for _, cookieValue := range setCookie {
if cookieValue == expCookieValue {
redirectToCookieFound = true
break
}
}
assert.True(t, redirectToCookieFound)
}) })
} }
} }
......
...@@ -47,7 +47,7 @@ func notAuthorized(c *m.ReqContext) { ...@@ -47,7 +47,7 @@ func notAuthorized(c *m.ReqContext) {
return return
} }
c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/", nil, false, true) WriteCookie(c.Resp, "redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, newCookieOptions)
c.Redirect(setting.AppSubUrl + "/login") c.Redirect(setting.AppSubUrl + "/login")
} }
......
package middleware
import (
"net/http"
"github.com/grafana/grafana/pkg/setting"
)
type CookieOptions struct {
Path string
Secure bool
SameSite http.SameSite
}
func newCookieOptions() CookieOptions {
return CookieOptions{
Path: setting.AppSubUrl + "/",
Secure: setting.CookieSecure,
SameSite: setting.CookieSameSite,
}
}
type GetCookieOptionsFunc func() CookieOptions
func DeleteCookie(w http.ResponseWriter, name string, getCookieOptionsFunc GetCookieOptionsFunc) {
WriteCookie(w, name, "", -1, getCookieOptionsFunc)
}
func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, getCookieOptionsFunc GetCookieOptionsFunc) {
options := getCookieOptionsFunc()
cookie := http.Cookie{
Name: name,
MaxAge: maxAge,
Value: value,
HttpOnly: true,
Path: options.Path,
Secure: options.Secure,
}
if options.SameSite != http.SameSiteDefaultMode {
cookie.SameSite = options.SameSite
}
http.SetCookie(w, &cookie)
}
...@@ -2,7 +2,6 @@ package middleware ...@@ -2,7 +2,6 @@ package middleware
import ( import (
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
...@@ -253,20 +252,7 @@ func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetimeDays in ...@@ -253,20 +252,7 @@ func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetimeDays in
maxAge = int(maxAgeHours.Seconds()) maxAge = int(maxAgeHours.Seconds())
} }
ctx.Resp.Header().Del("Set-Cookie") WriteCookie(ctx.Resp, setting.LoginCookieName, url.QueryEscape(value), maxAge, newCookieOptions)
cookie := http.Cookie{
Name: setting.LoginCookieName,
Value: url.QueryEscape(value),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: setting.CookieSecure,
MaxAge: maxAge,
}
if setting.CookieSameSite != http.SameSiteDefaultMode {
cookie.SameSite = setting.CookieSameSite
}
http.SetCookie(ctx.Resp, &cookie)
} }
func AddDefaultResponseHeaders() macaron.Handler { func AddDefaultResponseHeaders() macaron.Handler {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment