Skip to content
33 changes: 33 additions & 0 deletions docs/middleware/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,37 @@ extractors.Chain(
)
```

### Trusting Client-Supplied IDs from Read-Only Sources

By default, an unknown session ID from any source is discarded and a new one is generated via `KeyGenerator`. For cookie/header sources that is also the response channel for the new ID, so the next request continues with it. Read-only sources (query, form, URL param, custom extractors) cannot communicate a new ID back, so the same client request would otherwise create a new orphan session every time.
Comment thread
ReneWerner87 marked this conversation as resolved.
Outdated

If your application needs read-only sources to drive a persistent session, for example a non-browser client that always sends the same `?SESSIONID=...`, opt in explicitly:

```go
app.Use(session.New(session.Config{
Extractor: extractors.FromQuery("SESSIONID"),
TrustClientSessionID: true,
ClientSessionIDValidator: func(id string) bool {
// Verify the format/origin of the ID. Reject anything you did not issue.
// Example: HMAC-signed IDs, length checks, allow-list lookups, ...
return isValidSignedID(id)
},
}))
```

**Security implications.** Trusting client-supplied IDs without validation enables:

- **Session fixation**: an attacker can craft a link such as `?SESSIONID=ATTACKER_KNOWN_VALUE`; once the victim follows it, the server creates a session under that ID and the attacker can hijack it.
- **Storage poisoning**: any caller can populate your session storage with arbitrary keys.

Mitigations:

1. Always supply a `ClientSessionIDValidator` that rejects IDs you did not issue (HMAC signature, registered allow-list, signed JWT, etc.).
2. Combine the read-only source with a server-issued token bootstrap step.
3. Prefer cookie or header extractors whenever the client can store them.

Cookie and header sources are unaffected by this flag; their unknown IDs are always discarded to prevent fixation.

### Custom Extractors (Session-specific)

Prefer the helper constructors from the extractors module. See the Extractors Guide for the full API; below are session-specific examples and notes.
Expand Down Expand Up @@ -681,6 +712,8 @@ extractors.Chain(extractors ...extractors.Extractor) extractors.Extractor
| `Store` | `*session.Store` | Pre-built session store (use when you need to share/register types) | `nil` (auto-created) |
| `Storage` | `fiber.Storage` | Session storage backend (used when creating a store if `Store` is nil) | `memory.New()` |
| `Extractor` | `extractors.Extractor` | Session ID extraction | `extractors.FromCookie("session_id")` |
| `TrustClientSessionID` | `bool` | Accept client-supplied IDs from read-only sources (query/form/param/custom) when no data exists. Requires `ClientSessionIDValidator`. | `false` |
| `ClientSessionIDValidator` | `func(string) bool` | Validates a client-supplied session ID before persisting it. Required when `TrustClientSessionID` is `true`; `nil` rejects all. | `nil` |
| `KeyGenerator` | `func() string` | Session ID generator | `utils.SecureToken` |
| `IdleTimeout` | `time.Duration` | Inactivity timeout | `30 * time.Minute` |
| `AbsoluteTimeout` | `time.Duration` | Maximum session duration | `0` (unlimited) |
Expand Down
9 changes: 9 additions & 0 deletions extractors/extractors.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ const (
SourceCustom
)

// IsWritable reports whether the source allows the server to send a value back
// to the client in the response (e.g. Set-Cookie, response header). Cookie and
// header sources are writable; query, form, param, custom and auth-header
// sources are read-only; the client controls the value on every request and
// the server cannot rotate it in the response.
func (s Source) IsWritable() bool {
return s == SourceCookie || s == SourceHeader
}

// ErrNotFound is returned when the requested value is missing or empty.
var ErrNotFound = errors.New("value not found")

Expand Down
24 changes: 24 additions & 0 deletions extractors/extractors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -937,3 +937,27 @@ func Test_isValidToken68(t *testing.T) {
})
}
}

// go test -run Test_Source_IsWritable
func Test_Source_IsWritable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
src Source
want bool
}{
{name: "cookie is writable", src: SourceCookie, want: true},
{name: "header is writable", src: SourceHeader, want: true},
{name: "auth header is read-only", src: SourceAuthHeader, want: false},
{name: "query is read-only", src: SourceQuery, want: false},
{name: "form is read-only", src: SourceForm, want: false},
{name: "param is read-only", src: SourceParam, want: false},
{name: "custom is read-only", src: SourceCustom, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, tc.src.IsWritable())
})
}
}
31 changes: 31 additions & 0 deletions middleware/session/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ type Config struct {
// Optional. Default: utils.SecureToken
KeyGenerator func() string

// ClientSessionIDValidator is invoked for client-supplied session IDs from
// read-only sources before they are persisted. Return true to accept the
// ID, false to reject it (a fresh server-generated ID is used instead).
//
// Required when TrustClientSessionID is true; nil = reject all.
//
// Optional. Default: nil
ClientSessionIDValidator func(id string) bool

// CookieDomain defines the domain of the session cookie.
//
// Optional. Default: ""
Expand Down Expand Up @@ -87,6 +96,28 @@ type Config struct {
//
// Optional. Default: false
CookieSessionOnly bool

// TrustClientSessionID controls whether client-supplied session IDs from
// read-only sources (query, form, URL param, custom extractor) are accepted
// as-is when no session data exists yet for that ID.
Comment thread
ReneWerner87 marked this conversation as resolved.
Outdated
//
// When false (default), unknown IDs from read-only sources are discarded
// and the server generates a new ID via KeyGenerator. This matches the
// behavior already applied to cookie/header sources to prevent session
// fixation.
//
// When true, the client-supplied ID is preserved and a fresh session is
// stored under it, so subsequent requests carrying the same ID load the
// same session. ClientSessionIDValidator must be set in this case;
// otherwise the client ID is rejected and a new one is generated.
//
// SECURITY: Enabling this opens the door to session fixation and storage
// poisoning by clients that can choose arbitrary IDs. Use only with a
// strong validator (HMAC-signed, length/format-checked, monotonically
// issued, etc.) and prefer cookie/header extractors when possible.
//
// Optional. Default: false
TrustClientSessionID bool
}

// ConfigDefault provides the default configuration.
Expand Down
99 changes: 81 additions & 18 deletions middleware/session/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/extractors"
"github.com/gofiber/fiber/v3/internal/storage/memory"
"github.com/gofiber/fiber/v3/log"
)
Expand All @@ -27,6 +28,16 @@ const (
sessionIDContextKey sessionIDKey = iota
)

// sessionIDInfo bundles the resolved session ID with the extractor source that
// produced it. Both pieces are cached together in the request locals so that a
// second Store.Get within the same request returns a consistent answer. In
// particular, chained extractors keep their original source decision instead of
// being re-derived from the wrapper Extractor.Source.
type sessionIDInfo struct {
id string
source extractors.Source
}

// Store manages session data using the configured storage backend.
type Store struct {
Config
Expand Down Expand Up @@ -123,10 +134,16 @@ func (s *Store) getSession(c fiber.Ctx) (*Session, error) {
var rawData []byte
var err error

id, ok := c.Locals(sessionIDContextKey).(string)
if !ok {
id = s.getSessionID(c)
// Resolve the session ID and the source that produced it. The pair is cached
// in the request locals so a second call within the same request returns the
// same answer, including for chained extractors where the source is decided
// at extraction time and would otherwise be lost.
info, alreadyResolved := c.Locals(sessionIDContextKey).(sessionIDInfo)
if !alreadyResolved {
info = s.resolveSessionID(c)
c.Locals(sessionIDContextKey, info)
}
id := info.id

fresh := false // Session is not fresh initially; only set to true if we generate a new ID

Expand All @@ -137,16 +154,33 @@ func (s *Store) getSession(c fiber.Ctx) (*Session, error) {
return nil, err
}
if rawData == nil {
// Data not found, prepare to generate a new session
id = ""
switch {
case alreadyResolved:
// A prior call within this request already committed to this ID.
// Keep it so multiple Store.Get calls in the same request observe
// the same session.
fresh = true
Comment thread
ReneWerner87 marked this conversation as resolved.
case s.acceptClientID(info):
// Read-only source with an opt-in trusted client ID; preserve so
// that subsequent requests carrying the same ID load the same
// session.
fresh = true
default:
// Writable source (cookie/header) with an unknown ID, or
// untrusted read-only ID; discard and generate a fresh one to
// prevent session fixation and storage poisoning.
id = ""
}
}
}

// Generate a new ID if needed
if id == "" {
fresh = true // The session is fresh if a new ID is generated
id = s.KeyGenerator()
c.Locals(sessionIDContextKey, id)
// Mark the cached source as cookie so the regenerated ID is treated as
// server-issued (writable) on any subsequent call within this request.
c.Locals(sessionIDContextKey, sessionIDInfo{id: id, source: extractors.SourceCookie})
}

// Create session object
Expand Down Expand Up @@ -185,25 +219,54 @@ func (s *Store) getSession(c fiber.Ctx) (*Session, error) {
return sess, nil
}

// getSessionID returns the session ID using the configured extractor.
// The extractor is provided by the shared extractors package.
// resolveSessionID extracts the session ID from the request and reports the
// source that produced it. For chained extractors the sub-extractors are tried
// in order so the source of the first one that yields a value wins; for a
// single extractor the source on the wrapper is used. When extraction fails the
// returned ID is empty and the source falls back to the wrapper's source.
//
// Parameters:
// - c: The Fiber context.
//
// Returns:
// - string: The session ID.
//
// Usage:
//
// id := store.getSessionID(c)
func (s *Store) getSessionID(c fiber.Ctx) string {
sessionID, err := s.Extractor.Extract(c)
// - sessionIDInfo: The resolved ID together with its originating source.
func (s *Store) resolveSessionID(c fiber.Ctx) sessionIDInfo {
ext := s.Extractor

if len(ext.Chain) > 0 {
for _, chainExt := range ext.Chain {
if chainExt.Extract == nil {
continue
}
v, err := chainExt.Extract(c)
if err == nil && v != "" {
return sessionIDInfo{id: v, source: chainExt.Source}
}
}
return sessionIDInfo{source: ext.Source}
}

v, err := ext.Extract(c)
if err != nil {
// If extraction fails, return empty string to generate a new session
return ""
return sessionIDInfo{source: ext.Source}
}
return sessionIDInfo{id: v, source: ext.Source}
}

// acceptClientID reports whether a client-supplied session ID from a read-only
// source should be persisted as-is. Writable sources (cookie/header) are never
// accepted here; they are subject to fixation protection. For read-only
// sources the application must explicitly opt in via TrustClientSessionID and
// supply a ClientSessionIDValidator that accepts the ID; otherwise the ID is
// rejected and a server-generated one is used.
func (s *Store) acceptClientID(info sessionIDInfo) bool {
if info.id == "" || info.source.IsWritable() {
return false
}
if !s.TrustClientSessionID || s.ClientSessionIDValidator == nil {
return false
}
return sessionID
return s.ClientSessionIDValidator(info.id)
}

// Reset deletes all sessions from the storage.
Expand Down
Loading
Loading