Files
webhooker/internal/session/session.go
clawbot 7923146db9
All checks were successful
check / check (push) Successful in 3m28s
Decide request TLS in one place, per request (closes #269)
Two places decided whether a request was TLS, by two different means,
and they disagreed.

The session cookie's Secure attribute was fixed at startup from
!Config.IsDev(). "dev" is the environment when WEBHOOKER_ENVIRONMENT is
unset, so a deployment terminating TLS at a proxy without also setting
the environment shipped the authentication cookie with no Secure
attribute -- on the same response as a CSRF cookie that had one. It
failed silently: everything kept working, so nothing prompted anyone to
look.

The CSRF middleware's per-request check compared X-Forwarded-Proto with
== "https" exactly, so "HTTPS", "https, http" and "https,https" all took
the plaintext path. Uppercase is legal for a case-insensitive token and
the comma forms are what a proxy chained behind another proxy emits by
appending rather than replacing. On that path gorilla/csrf stops
enforcing the strict Referer check on a site that genuinely is HTTPS.

Both now go through internal/reqtls.IsTLS, which folds case and takes
the leftmost comma-separated element -- the hop nearest the client, and
so the one a cookie's Secure attribute is about. A third package is
needed because internal/middleware already imports internal/session, so
session cannot import middleware back.

Per-request beat a startup warning for the session cookie because it
turned out to need no restructuring: gorilla/sessions gives every
session its own copy of the store's Options and renders the cookie from
that copy, and every session-cookie write here already goes through
Session.Save or Session.Regenerate, both of which hold the request. The
store's template Secure becomes true so that a write path added later
which forgets to track the transport fails visibly instead of silently
dropping Secure.

The flag tracks the transport in both directions rather than latching
on. Secure over plaintext is discarded by the browser without an error,
which would make a plain-HTTP local run impossible to log into -- and
would also void the deletion cookies in Destroy and Regenerate, leaving
a session the user just tried to end still live.

A third site that makes this decision, internal/handlers'
BaseURL construction, assigns the raw header straight into the URL
scheme. It is left alone here and filed separately.
2026-08-24 00:50:10 +00:00

460 lines
14 KiB
Go

// Package session manages HTTP session storage and authentication
// state.
package session
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"time"
"github.com/gorilla/sessions"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/reqtls"
)
const (
// SessionName is the name of the session cookie.
SessionName = "webhooker_session"
// UserIDKey is the session key for user ID.
UserIDKey = "user_id"
// UsernameKey is the session key for username.
UsernameKey = "username"
// AuthenticatedKey is the session key for authentication
// status.
AuthenticatedKey = "authenticated"
// CreatedAtKey is the session key holding the Unix timestamp at
// which the session was authenticated. It anchors the ABSOLUTE
// expiry clock and is written exactly once, by SetUser. Nothing
// refreshes it: an absolute deadline that moved with activity
// would not be a cap at all.
CreatedAtKey = "created_at"
// LastSeenKey is the session key holding the Unix timestamp of
// the most recent authenticated request. It anchors the IDLE
// expiry clock and is pushed forward by Touch.
LastSeenKey = "last_seen"
// sessionKeyLength is the required length in bytes for the
// session authentication key.
sessionKeyLength = 32
// sessionMaxAgeDays is the session cookie lifetime in days.
sessionMaxAgeDays = 7
// secondsPerDay is the number of seconds in a day.
secondsPerDay = 86400
// sessionAbsoluteMaxAge is the hard upper bound on how long a
// session may live, measured from CreatedAtKey. Activity never
// extends it, so even a continuously used session ends here and
// the user has to authenticate again.
sessionAbsoluteMaxAge = sessionMaxAgeDays * secondsPerDay * time.Second
// idleRefreshDivisor rate-limits idle-deadline refreshes. Touch
// only rewrites LastSeenKey once the stored value is older than
// idleTimeout/idleRefreshDivisor, so an active session is
// re-saved at most this many times per idle window instead of
// once per request. See Touch for the tradeoff this buys.
idleRefreshDivisor = 10
)
// ErrSessionKeyLength is returned when the decoded session key
// does not have the expected length.
var ErrSessionKeyLength = errors.New("session key length mismatch")
// Params holds dependencies injected by fx.
type Params struct {
fx.In
Config *config.Config
Database *database.Database
Logger *logger.Logger
}
// Session manages encrypted session storage.
type Session struct {
store *sessions.CookieStore
key []byte // raw 32-byte auth key, also used for CSRF cookie signing
log *slog.Logger
// idleTimeout is the sliding inactivity window. A session that
// sees no authenticated request within this window expires,
// independently of the absolute cap. Non-positive disables idle
// expiry and leaves sessionAbsoluteMaxAge as the only bound.
idleTimeout time.Duration
// now reads the current time. Injected so expiry can be tested
// without sleeping.
now func() time.Time
}
// cookieOptions returns the cookie attributes used for every session
// cookie. MaxAge is deliberately left at its zero value: for a store
// it is set through CookieStore.MaxAge (see newStore), and for a
// single session it is copied from the store's options.
//
// Secure is a parameter rather than a constant because it is the one
// attribute here that is not a policy -- it is a fact about the
// connection carrying this particular response. See applyTransport.
func cookieOptions(secure bool) *sessions.Options {
return &sessions.Options{
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}
}
// newStore builds the session cookie store.
//
// The absolute cap MUST be applied with store.MaxAge and not by
// assigning store.Options.MaxAge. NewCookieStore gives the underlying
// securecookie codecs a 30-day max age of their own, and assigning
// Options never touches Codecs -- so a store configured that way still
// decodes a 30-day-old cookie, leaving the cookie attribute and the
// codec disagreeing about the same policy. store.MaxAge sets both.
//
// The store's Secure is fixed at true, and is only a template: every
// write path overwrites it for the request in hand (applyTransport).
// It is true rather than false so that a write path added later which
// forgets to call applyTransport fails loudly -- the browser drops the
// cookie over plaintext HTTP and the developer sees it immediately --
// instead of silently shipping the authentication credential without
// Secure, which is the exact failure this store already had once.
func newStore(key []byte) *sessions.CookieStore {
store := sessions.NewCookieStore(key)
store.Options = cookieOptions(true)
store.MaxAge(secondsPerDay * sessionMaxAgeDays)
return store
}
// applyTransport sets the session cookie's Secure attribute from the
// transport of the request being answered.
//
// This is decided per-request, not once at startup. Deciding it at
// startup from the configured environment is what this replaces, and
// it got the DEFAULT posture wrong: "dev" is the environment when
// WEBHOOKER_ENVIRONMENT is unset, so a deployment terminating TLS at a
// proxy without also setting the environment emitted the
// authentication cookie with no Secure attribute -- silently, and on
// the same response as a CSRF cookie that did have one.
//
// gorilla/sessions makes this cheap and local: CookieStore.New gives
// every session its own copy of the store's Options, and
// CookieStore.Save renders the cookie from that copy rather than from
// the store. So the flag is set on the one session being saved,
// without a second store and without reaching across concurrent
// requests.
//
// The flag tracks the transport in BOTH directions rather than being
// latched on once seen. Secure on a plaintext response is worse than
// useless: the browser discards such a cookie without any error, so a
// latched flag would make a plain-HTTP local run impossible to log
// into. It is also why every write path must call this, including the
// deletion cookies in Destroy and Regenerate -- a Secure deletion
// cookie sent over plaintext is dropped too, leaving the session the
// caller believed it had just revoked.
func applyTransport(r *http.Request, sess *sessions.Session) {
sess.Options.Secure = reqtls.IsTLS(r)
}
// New creates a new session manager. The cookie store is
// initialized during the fx OnStart phase after the database is
// connected, using a session key that is auto-generated and stored
// in the database.
func New(
lc fx.Lifecycle,
params Params,
) (*Session, error) {
s := &Session{
log: params.Logger.Get(),
idleTimeout: params.Config.SessionIdleTimeout,
now: time.Now,
}
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
sessionKey, err := params.Database.GetOrCreateSessionKey()
if err != nil {
return fmt.Errorf(
"failed to get session key: %w", err,
)
}
keyBytes, err := base64.StdEncoding.DecodeString(
sessionKey,
)
if err != nil {
return fmt.Errorf(
"invalid session key format: %w", err,
)
}
if len(keyBytes) != sessionKeyLength {
return fmt.Errorf(
"%w: want %d, got %d",
ErrSessionKeyLength,
sessionKeyLength,
len(keyBytes),
)
}
s.key = keyBytes
s.store = newStore(keyBytes)
s.log.Info("session manager initialized")
return nil
},
})
return s, nil
}
// Get retrieves a session for the request.
func (s *Session) Get(
r *http.Request,
) (*sessions.Session, error) {
return s.store.Get(r, SessionName)
}
// GetKey returns the raw 32-byte authentication key used for
// session encryption. This key is also suitable for CSRF cookie
// signing.
func (s *Session) GetKey() []byte {
return s.key
}
// Save saves the session. Every session-cookie write in the
// application goes through here or through Regenerate, which is what
// makes applyTransport a complete answer rather than a best effort.
func (s *Session) Save(
r *http.Request,
w http.ResponseWriter,
sess *sessions.Session,
) error {
applyTransport(r, sess)
return sess.Save(r, w)
}
// SetUser sets the user information in the session. It starts both
// expiry clocks: CreatedAtKey (absolute, never refreshed again) and
// LastSeenKey (idle, refreshed by Touch).
func (s *Session) SetUser(
sess *sessions.Session,
userID, username string,
) {
now := s.now().Unix()
sess.Values[UserIDKey] = userID
sess.Values[UsernameKey] = username
sess.Values[AuthenticatedKey] = true
sess.Values[CreatedAtKey] = now
sess.Values[LastSeenKey] = now
}
// ClearUser removes user information from the session, including
// both expiry timestamps.
func (s *Session) ClearUser(sess *sessions.Session) {
delete(sess.Values, UserIDKey)
delete(sess.Values, UsernameKey)
delete(sess.Values, AuthenticatedKey)
delete(sess.Values, CreatedAtKey)
delete(sess.Values, LastSeenKey)
}
// sessionTime reads a Unix-second timestamp stored under key.
func sessionTime(
sess *sessions.Session,
key string,
) (time.Time, bool) {
secs, ok := sess.Values[key].(int64)
if !ok {
return time.Time{}, false
}
return time.Unix(secs, 0), true
}
// IsAuthenticated checks if the session has an authenticated user
// whose session has not passed either expiry deadline. Every
// authentication decision goes through here, so neither clock can
// be bypassed by a caller that forgets to check it.
func (s *Session) IsAuthenticated(sess *sessions.Session) bool {
auth, ok := sess.Values[AuthenticatedKey].(bool)
if !ok || !auth {
return false
}
return !s.expired(sess)
}
// Touch records authenticated activity by pushing the IDLE deadline
// forward. It writes LastSeenKey only; CreatedAtKey is left alone so
// the absolute cap keeps counting down even for a user who never
// stops clicking.
//
// Callers must only invoke Touch for a request that authenticated
// with this session. Refreshing on an unauthenticated request would
// let anyone holding a stolen or abandoned cookie keep the session
// alive by polling a public endpoint. Touch enforces that itself by
// returning false for any session that is not currently
// authenticated and unexpired.
//
// To avoid re-encrypting and re-emitting the session cookie on every
// single request, the timestamp is advanced only once it is older
// than idleTimeout/idleRefreshDivisor. The tradeoff is that
// LastSeenKey lags real activity by up to that much, so a session
// can expire slightly early relative to the user's true last
// request -- never late.
//
// Touch reports whether it changed the session; only then does the
// caller need to save it.
func (s *Session) Touch(sess *sessions.Session) bool {
if s.idleTimeout <= 0 {
return false
}
if !s.IsAuthenticated(sess) {
return false
}
now := s.now()
lastSeen, ok := sessionTime(sess, LastSeenKey)
if ok && now.Sub(lastSeen) < s.idleTimeout/idleRefreshDivisor {
return false
}
sess.Values[LastSeenKey] = now.Unix()
return true
}
// GetUserID retrieves the user ID from the session.
func (s *Session) GetUserID(
sess *sessions.Session,
) (string, bool) {
userID, ok := sess.Values[UserIDKey].(string)
return userID, ok
}
// GetUsername retrieves the username from the session.
func (s *Session) GetUsername(
sess *sessions.Session,
) (string, bool) {
username, ok := sess.Values[UsernameKey].(string)
return username, ok
}
// Destroy invalidates the session.
func (s *Session) Destroy(sess *sessions.Session) {
sess.Options.MaxAge = -1
s.ClearUser(sess)
}
// Regenerate creates a new session with the same values but a
// fresh ID. The old session is destroyed (MaxAge = -1) and saved,
// then a new session is created. This prevents session fixation
// attacks by ensuring the session ID changes after privilege
// escalation (e.g. login).
func (s *Session) Regenerate(
r *http.Request,
w http.ResponseWriter,
oldSess *sessions.Session,
) (*sessions.Session, error) {
// Copy the values from the old session
oldValues := make(map[any]any)
maps.Copy(oldValues, oldSess.Values)
// Destroy the old session
oldSess.Options.MaxAge = -1
s.ClearUser(oldSess)
applyTransport(r, oldSess)
err := oldSess.Save(r, w)
if err != nil {
return nil, fmt.Errorf(
"failed to destroy old session: %w", err,
)
}
// Create a new session (gorilla/sessions generates a new ID)
newSess, err := s.store.New(r, SessionName)
if err != nil {
// store.New may return an error alongside a new empty
// session if the old cookie is now invalid. That is
// expected after we destroyed it above. Only fail on a
// nil session.
if newSess == nil {
return nil, fmt.Errorf(
"failed to create new session: %w", err,
)
}
}
// Restore the copied values into the new session
maps.Copy(newSess.Values, oldValues)
// Apply the standard session options (the destroyed old
// session had MaxAge = -1, which store.New might inherit
// from the cookie).
newSess.Options = cookieOptions(reqtls.IsTLS(r))
newSess.Options.MaxAge = secondsPerDay * sessionMaxAgeDays
return newSess, nil
}
// expired reports whether the session has passed either of its two
// independent deadlines. They are deliberately kept apart:
//
// - the ABSOLUTE deadline is CreatedAtKey + sessionAbsoluteMaxAge.
// It is fixed at login and no amount of activity moves it.
// - the IDLE deadline is LastSeenKey + idleTimeout. Activity moves
// it forward via Touch.
//
// Whichever comes first ends the session.
//
// A session that claims to be authenticated but carries no
// timestamps predates this check; it is treated as expired so the
// user re-authenticates rather than being granted an unbounded
// session.
func (s *Session) expired(sess *sessions.Session) bool {
now := s.now()
createdAt, ok := sessionTime(sess, CreatedAtKey)
if !ok {
return true
}
if !now.Before(createdAt.Add(sessionAbsoluteMaxAge)) {
return true
}
if s.idleTimeout <= 0 {
return false
}
lastSeen, ok := sessionTime(sess, LastSeenKey)
if !ok {
return true
}
return !now.Before(lastSeen.Add(s.idleTimeout))
}