Skip to content

providers/memory: getSessionKey uses strconv.B2S, stored map key aliases reusable request cookie buffer #53

Description

@Aransh

Disclaimer: found while debugging an Authelia issue with the help of Claude (Opus 5.5); findings verified with the repro below.

Downstream report: authelia/authelia#13292 (Authelia vendors a copy of this provider).

Version

github.com/fasthttp/session/v2 v2.5.9 (current master, 7082691)

Problem

providers/memory/provider.go:

func (p *Provider) getSessionKey(sessionID []byte) string {
	return strconv.B2S(sessionID)
}

strconv.B2S (savsgio/gotils) is unsafe.String, a zero-copy view of the bytes. Save and Regenerate store
that string as a map key. When the session ID comes from the request (Session.getSessionID →
ctx.Request.Header.Cookie(name)), those bytes belong to fasthttp's reusable header/cookie buffer. The stored
key therefore mutates when the buffer is reused by a later request, and lookups for the correct ID miss.

Newly generated IDs are separate allocations, so the bug only affects sessions whose first Save uses a
client-supplied ID unknown to the store, for example a stale cookie after a server restart, since memory storage is
empty. In Authelia this breaks the WebAuthn passkey flow (challenge saved on GET, missing on the following POST).

Reproduction

package main

import (
	"fmt"
	"io"
	"net"
	"net/http"

	"github.com/fasthttp/session/v2"
	"github.com/fasthttp/session/v2/providers/memory"
	"github.com/valyala/fasthttp"
)

func main() {
	prov, _ := memory.New(memory.Config{})
	cfg := session.NewDefaultConfig()
	cfg.CookieName, cfg.Secure = "session", false
	sess := session.New(cfg)
	_ = sess.SetProvider(prov)

	ln, _ := net.Listen("tcp", "127.0.0.1:0")
	go fasthttp.Serve(ln, func(ctx *fasthttp.RequestCtx) {
		store, _ := sess.Get(ctx)
		if string(ctx.Path()) == "/set" {
			store.Set("k", "v")
			_ = sess.Save(ctx, store)
			return
		}
		if store.Get("k") == nil {
			fmt.Fprint(ctx, "MISSING")
		} else {
			fmt.Fprint(ctx, "PRESENT")
		}
	})

	c := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 1}} // single keep-alive conn
	req := func(path, cookie string) string {
		r, _ := http.NewRequest("GET", "http://"+ln.Addr().String()+path, nil)
		r.Header.Set("Cookie", cookie)
		resp, err := c.Do(r)
		if err != nil {
			return err.Error()
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}

	// Client-supplied ID unknown to the store (e.g. stale cookie after restart).
	req("/set", "session=STALEbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
	// Same session cookie, different position in the Cookie header.
	fmt.Println("result:", req("/get", "x=1; session=STALEbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
}

Output: result: MISSING. Dumping the map keys afterwards shows the stored key changed from STALEbbbb… to
1TALEbbbb…: the first byte was overwritten by the other cookie's value in the reused buffer.

Expected

result: PRESENT, which is what you get with getSessionKey returning string(sessionID).

Suggested fix

Copy the ID when it becomes a stored key:

func (p *Provider) getSessionKey(sessionID []byte) string {
	return string(sessionID)
}

Alternatively, keep B2S for Get/Destroy, since those lookups don't retain the key, and copy only in
Save/Regenerate. It may also be worth copying the ID in Session.Get itself (store.sessionID), since any provider
that retains the slice has the same exposure. The Redis provider is unaffected because it builds its key
in a buffer and String() copies it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions