Derive cookie Secure and CSRF strictness from the request transport (closes #269)
All checks were successful
check / check (push) Successful in 2m56s
All checks were successful
check / check (push) Successful in 2m56s
This commit was merged in pull request #276.
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"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 (
|
||||
@@ -84,10 +85,9 @@ type Params struct {
|
||||
|
||||
// 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
|
||||
config *config.Config
|
||||
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,
|
||||
@@ -104,6 +104,10 @@ type Session struct {
|
||||
// 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: "/",
|
||||
@@ -121,14 +125,52 @@ func cookieOptions(secure bool) *sessions.Options {
|
||||
// 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.
|
||||
func newStore(key []byte, secure bool) *sessions.CookieStore {
|
||||
//
|
||||
// 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(secure)
|
||||
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
|
||||
@@ -139,7 +181,6 @@ func New(
|
||||
) (*Session, error) {
|
||||
s := &Session{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
idleTimeout: params.Config.SessionIdleTimeout,
|
||||
now: time.Now,
|
||||
}
|
||||
@@ -172,7 +213,7 @@ func New(
|
||||
}
|
||||
|
||||
s.key = keyBytes
|
||||
s.store = newStore(keyBytes, !params.Config.IsDev())
|
||||
s.store = newStore(keyBytes)
|
||||
s.log.Info("session manager initialized")
|
||||
|
||||
return nil
|
||||
@@ -196,12 +237,16 @@ func (s *Session) GetKey() []byte {
|
||||
return s.key
|
||||
}
|
||||
|
||||
// Save saves the session.
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -340,6 +385,7 @@ func (s *Session) Regenerate(
|
||||
// Destroy the old session
|
||||
oldSess.Options.MaxAge = -1
|
||||
s.ClearUser(oldSess)
|
||||
applyTransport(r, oldSess)
|
||||
|
||||
err := oldSess.Save(r, w)
|
||||
if err != nil {
|
||||
@@ -368,7 +414,7 @@ func (s *Session) Regenerate(
|
||||
// Apply the standard session options (the destroyed old
|
||||
// session had MaxAge = -1, which store.New might inherit
|
||||
// from the cookie).
|
||||
newSess.Options = cookieOptions(!s.config.IsDev())
|
||||
newSess.Options = cookieOptions(reqtls.IsTLS(r))
|
||||
newSess.Options.MaxAge = secondsPerDay * sessionMaxAgeDays
|
||||
|
||||
return newSess, nil
|
||||
|
||||
Reference in New Issue
Block a user