iris icon indicating copy to clipboard operation
iris copied to clipboard

[BUG] Recreating session (cookie) with securecookie enabled passes plain cookie value sometimes

Open mblaschke opened this issue 1 year ago • 14 comments

Describe the bug When using securecookie for session it fails sometime because plain (decoded) cookie value is passed to decode function.

securecookie decode fails with "securecookie: the value is not valid" on MAC check/decode: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L323

To Reproduce

package main

import (
	"net/http"

	"github.com/gorilla/securecookie"
	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/context"
	"github.com/kataras/iris/v12/sessions"
)

const cookieNameForSessionID = "session_id_cookie"

var sess *sessions.Sessions

func secret(ctx iris.Context) {
	session := getSession(ctx, false)

	// Check if user is authenticated
	if auth, _ := session.GetBoolean("authenticated"); !auth {
		ctx.StatusCode(iris.StatusForbidden)
		ctx.ContentType("text/html")
		ctx.WriteString(`Not logged in or session invalid <a href="/logout">logout</a>`)
		return
	}

	// Print secret message
	ctx.ContentType("text/html")
	ctx.WriteString(`The cake is a lie! <a href="/logout">logout</a>`)
}

func login(ctx iris.Context) {
	session := getSession(ctx, false)
	// Authentication goes here
	// ...

	// Set user as authenticated
	session = getSession(ctx, true)
	session.Set("authenticated", true)
	ctx.StatusCode(http.StatusTemporaryRedirect)
	ctx.ContentType("text/html")
	ctx.Header("Location", "/secret")
}

func logout(ctx iris.Context) {
	sess.Destroy(ctx)
	ctx.ContentType("text/html")
	ctx.WriteString(`<a href="/login">login</a>`)
}

func getSession(ctx iris.Context, recreate bool) *sessions.Session {
	cookieOptionList := []context.CookieOption{
		func(ctx *context.Context, cookie *http.Cookie, op uint8) {
			// c.applySessionSetting(ctx, cookie, op)
		},
	}

	if recreate {
		sess.Destroy(ctx)
	}
	s := sess.Start(ctx, cookieOptionList...)
	return s
}

func main() {
	app := iris.New()
	secureCookie := securecookie.New(
		[]byte("IGVneS5eZ0b5jTgwe0l38nON0bW5awxr"),
		[]byte("r4GFiLvBtYCohUxt"),
	)

	sess = sessions.New(sessions.Config{
		Cookie:                      cookieNameForSessionID,
		Encoding:                    secureCookie,
		CookieSecureTLS:             false,
		AllowReclaim:                true,
		DisableSubdomainPersistence: true,
	})
	app.Use(sess.Handler())
	// ^ or comment this line and use sess.Start(ctx) inside your handlers
	// instead of sessions.Get(ctx).

	app.Get("/secret", secret)
	app.Get("/login", login)
	app.Get("/logout", logout)

	app.Listen(":8080")
}

and add alter Decode function in securecookie:

func (s *SecureCookie) Decode(name, value string, dst interface{}) error {
	fmt.Println(value)

result is:

MTY1Nzk3Nzg4MnxOTy0tV2JmWi1xbExTRlAzQXRMbXJJWV82S0F4dDJ5eGpSZWg5ODRydk1qdDBpVm1ZeXpKWUpMSExCWUY0M2ItZFZfZkQ2bm8yUUU9fAmHNHj68vBKoujiboz1UMf5r2IguTm-zoHQJ0DSJDpM
MTY1Nzk3Nzg4MnxOTy0tV2JmWi1xbExTRlAzQXRMbXJJWV82S0F4dDJ5eGpSZWg5ODRydk1qdDBpVm1ZeXpKWUpMSExCWUY0M2ItZFZfZkQ2bm8yUUU9fAmHNHj68vBKoujiboz1UMf5r2IguTm-zoHQJ0DSJDpM
a95828e7-a194-49e8-89cf-d76a987faa2e
a95828e7-a194-49e8-89cf-d76a987faa2e
a95828e7-a194-49e8-89cf-d76a987faa2e
MTY1Nzk3Nzg4OXxPSGs2YVNhQThFRXYyVmkwWkl5OTQ1bm5vNDVNRmxsUHRZUFNCdGFBRW12aGZIZ3dnaW1naVNDZjkyTXZLRWU1Nkc1QkxTVWdoc2M9fG38bjG-RwEYLoostgZErggXc07I_a28N2vKS-99Uf1P
MTY1Nzk3Nzg4OXxPSGs2YVNhQThFRXYyVmkwWkl5OTQ1bm5vNDVNRmxsUHRZUFNCdGFBRW12aGZIZ3dnaW1naVNDZjkyTXZLRWU1Nkc1QkxTVWdoc2M9fG38bjG-RwEYLoostgZErggXc07I_a28N2vKS-99Uf1P

Desktop (please complete the following information):

  • OS: osx

iris.Version

  • v12.2.0-beta3
  • master

mblaschke avatar Jul 16 '22 13:07 mblaschke

I haven't found the code where this happens but my rough guess: When a new session is created it's passed to the decode function which fails because the session was never encoded.

mblaschke avatar Jul 16 '22 14:07 mblaschke

Hello @mblaschke,

In your code example you initialize the session on each handler through:

app.Use(sess.Handler())

Then, on your getSession function, it calls the Destroy and if getSession is called from the login function, it calls the session's Start on the same request-response lifecycle, can you explain me why? Sessions are not supposed to work like this, please describe the issue so I can provide a better solution. Meanwhile, I will keep investigate why the plain cookie value is passed when securecookie is enabled.

kataras avatar Jul 16 '22 16:07 kataras

i reused the example from https://github.com/kataras/iris/issues/1877

should i avoid using?

app.Use(sess.Handler())

I tried to verify the issue why sometimes sessions are dropped (still working on an example) so i found the issue behind it that plain text session values are passed to securecookie.

mblaschke avatar Jul 16 '22 17:07 mblaschke

You should use (app.Use(sess.Handler()) and sessions.Get) or Start - not both. I can't re-produce the issue. I see encoded values on securecookie.Decode method:

SecureCookie.Encode: name = session_id_cookie, value = "6ef75d2a-adba-4c77-a9e9-645548786364"
SecureCookie.Decode: name = session_id_cookie, value = MTY1Nzk5MTI1N3xwOUdRbDBRS2NWbWVFcmZCcTdfU3REWDEzYmp5Tm83VTRmbko4eDFpSnd4SjBZclJHYzI2VXMybmpFVmRzY2JlREhOMTNWU2R4ZU09fFR9KtqAGcJRW_xQJSmr2pkFjyfxAE-64q2SSYdM582w
SecureCookie.Decode: name = session_id_cookie, value = MTY1Nzk5MTI1N3xwOUdRbDBRS2NWbWVFcmZCcTdfU3REWDEzYmp5Tm83VTRmbko4eDFpSnd4SjBZclJHYzI2VXMybmpFVmRzY2JlREhOMTNWU2R4ZU09fFR9KtqAGcJRW_xQJSmr2pkFjyfxAE-64q2SSYdM582w

kataras avatar Jul 16 '22 17:07 kataras

@mblaschke the code part, the securecookie's Decode is called is at: https://github.com/kataras/iris/blob/cf42f37169c43b5bc7b5911614ba51da403b38e9/context/context.go#L5314-L5318

If the decode is failed, the cookie value is set to empty string for security reasons. This may be happening if the previous Encode is not called for some reason, probably because you call Destroy and Start in the same handler for the same request-response lifecycle.

kataras avatar Jul 16 '22 17:07 kataras

@mblaschke this is a working example which destroys and re-initializes the session in the same request-response lifecycle (I don't recommend it) but here you are:

package main

import (
	"fmt"
	"net/http"

	"github.com/gorilla/securecookie"
	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/sessions"
)

const cookieNameForSessionID = "session_id_cookie"

func secret(ctx iris.Context) {
	session := getSession(ctx, false)

	// Check if user is authenticated
	if auth, _ := session.GetBoolean("authenticated"); !auth {
		ctx.StatusCode(iris.StatusForbidden)
		ctx.ContentType("text/html")
		ctx.WriteString(`Not logged in or session invalid <a href="/logout">logout</a>`)
		return
	}

	// Print secret message
	ctx.ContentType("text/html")
	ctx.WriteString(`The cake is a lie! <a href="/logout">logout</a>`)
}

func login(ctx iris.Context) {
	// Authentication goes here
	// ...

	// Set user as authenticated
	session := getSession(ctx, true)
	session.Set("authenticated", true)
	ctx.StatusCode(http.StatusTemporaryRedirect)
	ctx.ContentType("text/html")
	ctx.Header("Location", "/secret")
}

func logout(ctx iris.Context) {
	sessions.Get(ctx).Destroy()
	ctx.ContentType("text/html")
	ctx.WriteString(`<a href="/login">login</a>`)
}

func getSession(ctx iris.Context, recreate bool) *sessions.Session {
	// cookieOptionList := []context.CookieOption{
	// 	func(ctx *context.Context, cookie *http.Cookie, op uint8) {
	// 		// c.applySessionSetting(ctx, cookie, op)
	// 	},
	// }

	if recreate {
		sessions.Get(ctx).Destroy()
		fmt.Println("getSession.recreate: destroy called")
	}

	//	s := sess.Start(ctx, cookieOptionList...)
	//	return s
	return sessions.Get(ctx)
}

func main() {
	app := iris.New()

	secureCookie := securecookie.New(
		[]byte("IGVneS5eZ0b5jTgwe0l38nON0bW5awxr"),
		[]byte("r4GFiLvBtYCohUxt"),
	)

	sess := sessions.New(sessions.Config{
		Cookie:                      cookieNameForSessionID,
		Encoding:                    secureCookie,
		CookieSecureTLS:             false,
		AllowReclaim:                true,
		DisableSubdomainPersistence: true,
	})
	app.Use(sess.Handler())

	app.Get("/secret", secret)
	app.Get("/login", login)
	app.Get("/logout", logout)

	app.Listen(":8080")
}

It's the same but you use app.Use/sessions.Get(ctx) and sessions.Get(ctx).Destroy instead.

kataras avatar Jul 16 '22 17:07 kataras

@mblaschke,

I found the origin of the "issue". This is happening because the login handler has decoded the request cookie's value already - but this is not a problem, the server never sends the decoded value back to the client.

kataras avatar Jul 16 '22 17:07 kataras

I can confirm the session id is not send back to the client but the session is invalidated at that point.

The reason for recreation of session is https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#renew-the-session-id-after-any-privilege-level-change

Also I want to change cookies samesite mode from lax (for oauth login) to strict (for application).

mblaschke avatar Jul 17 '22 14:07 mblaschke

Hello @mblaschke,

Also I want to change cookies samesite mode from lax (for oauth login) to strict (for application).

That's easy, you can do it using a middleware (for all cookies) and cookie options via ctx.AddCookieOptions or pass the cookie option(s) when calling the sessions.Handler method. There is a builtin cookie option called CookieSameSite in the context sub-package but you can define your own, example:

var strictSameSiteOpt = func(_ iris.Context, c *http.Cookie, op uint8) {
	if op == 1 /* context.OpCookieSet */ {
            c.SameSite = http.SameSiteStrictMode
        }
}
	sess := sessions.New(sessions.Config{
		Cookie:                      cookieNameForSessionID,
		Encoding:                    secureCookie,
		CookieSecureTLS:             false,
		AllowReclaim:                true,
		DisableSubdomainPersistence: true,
	})
	


	app.Use(sess.Handler(strictSameSiteOpt))

The reason for recreation of session is https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#renew-the-session-id-after-any-privilege-level-change

Can you run this and provide some feedback? It works here:

package main

import (
	"github.com/gorilla/securecookie"
	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/sessions"
)

var strictSameSiteOpt = func(_ iris.Context, c *http.Cookie, op uint8) {
	if op == 1 /* context.OpCookieSet */ {
            c.SameSite = http.SameSiteStrictMode
        }
}

var sessManager *sessions.Sessions

const sessionCookieName = "session_id"

func main() {
	app := iris.New()

	secureCookie := securecookie.New(
		[]byte("IGVneS5eZ0b5jTgwe0l38nON0bW5awxr"),
		[]byte("r4GFiLvBtYCohUxt"),
	)

	sessManager = sessions.New(sessions.Config{
		Cookie:                      sessionCookieName,
		Encoding:                    secureCookie,
		CookieSecureTLS:             false,
		AllowReclaim:                true,
		DisableSubdomainPersistence: true,
	})
	app.Use(sessManager.Handler(strictSameSiteOpt))

	app.Get("/login", login)
	app.Get("/logout", logout)
	app.Get("/secret", secret)

	app.Listen(":8080")
}

func login(ctx iris.Context) {
	// Authentication goes here
	session := sessions.Get(ctx)
	// ...
	oldSessionID := session.ID()
	ctx.Application().Logger().Infof("old session id = %s", oldSessionID)

	// Set user as authenticated
	// If AllowReclaim is false:
	// manually remove the request cookie, so the new Start creates a new session:
	// ctx.Request().Header.Del(sessionCookieName)

	// Use session's manager instead of session.Destroy so
	// it destroys the response AND request (if AllowReclaim is true)
	// cookie information.
	sessManager.Destroy(ctx)

	session = sessManager.Start(ctx, strictSameSiteOpt)
	session.Set("authenticated", true)

	ctx.Application().Logger().Infof("new session id = %s", session.ID())

	ctx.Redirect("/secret", iris.StatusTemporaryRedirect)
}

func logout(ctx iris.Context) {
	sessManager.Destroy(ctx)

	ctx.HTML(`<a href="/login">login</a>`)
}

func secret(ctx iris.Context) {
	session := sessions.Get(ctx)

	// Check if user is authenticated.
	if auth, _ := session.GetBoolean("authenticated"); !auth {
		ctx.StatusCode(iris.StatusForbidden)
		ctx.HTML(`Not logged in or session invalid <a href="/logout">logout</a>`)
		return
	}

	// Print secret message.
	ctx.HTML(`The cake is a lie! <a href="/logout">logout</a>`)
}

kataras avatar Jul 22 '22 09:07 kataras

It's working but i found a way to reproduce (but not with an example app) it so i'm currently digging into the iris code (12.2.0-beta4) to find a clue what's happening.

If the user doesn't have a session, everything is fine.

When the user is redirected to oauth provider and I restart the app (using internal session storage) the login fails. with the error message i'm using c.session.Destroy(ctx) to destroy the session but the session is NOT destroyed and instead creates a new session:

Set-Cookie: sid=29b213ad-e8a4-4208-a0d7-d4f5e5f76500; Path=/; Expires=Sat, 23 Jul 2022 17:12:36 GMT; Max-Age=7199; HttpOnly; SameSite=Strict

on the login page (where i start oauth and redirect to user to the provider) i then get a for whatever reason:

Set-Cookie: devconsole-sid=; Path=/; Expires=Tue, 10 Nov 2009 23:00:00 GMT; Max-Age=0; HttpOnly

so the user is trapped in an endless invalid session loop.

also app.Use(sessManager.Handler(strictSameSiteOpt)) sets session ids for app.HandleDir requests which should never happen. I guess that's a major issue because that recreates a strict cookie again.

while trying several things sometimes i encounter a invalid memory address or nil pointer dereference:

runtime error: invalid memory address or nil pointer dereference
/opt/homebrew/Cellar/go/1.18.4/libexec/src/runtime/panic.go:838
/opt/homebrew/Cellar/go/1.18.4/libexec/src/runtime/panic.go:220
/opt/homebrew/Cellar/go/1.18.4/libexec/src/runtime/signal_unix.go:818
/app/vendor/github.com/kataras/iris/v12/sessions/session.go:63
/app/vendor/github.com/kataras/iris/v12/sessions/session.go:135

mblaschke avatar Jul 23 '22 15:07 mblaschke

to fix the endless invalid session loop i've moved RemoveCookie up before the cookieValue check. this at least solved to kill the invalid session.

github.com/kataras/iris/v12/sessions/sessions.go:

// Destroy removes the session data, the associated cookie
// and the Context's session value.
// Next calls of `sessions.Get` will occur to a nil Session,
// use `Sessions#Start` method for renewal
// or use the Session's Destroy method which does keep the session entry with its values cleared.
func (s *Sessions) Destroy(ctx *context.Context) {
	cookieValue := s.getCookieValue(ctx, nil)
	ctx.RemoveCookie(s.config.Cookie, s.cookieOptions...)

	if cookieValue == "" { // nothing to destroy
		return
	}

	ctx.Values().Remove(sessionContextKey)
	//ctx.RemoveCookie(s.config.Cookie, s.cookieOptions...)


	s.provider.Destroy(cookieValue)
}

mblaschke avatar Jul 23 '22 15:07 mblaschke

found one issue: i've forgot to enable AllowReclaim. Then it's working but only without securecookie 🤔

mblaschke avatar Jul 24 '22 23:07 mblaschke

also app.Use(sessManager.Handler(strictSameSiteOpt)) sets session ids for app.HandleDir requests which should never happen. I guess that's a major issue because that recreates a strict cookie again.

It should happen if you have allow it, you should register the middleware after the app.HandleDir to prevent it from running to your app.HandleDir.

kataras avatar Sep 22 '22 21:09 kataras

i've forgot to enable AllowReclaim. Then it's working but only without securecookie 🤔

You mean that the loop you said, is still valid? If so, could you please post an example with steps to re-produce it and test the solution by myself?

kataras avatar Sep 22 '22 21:09 kataras