Skip to content

Commit cf5b8f3

Browse files
authored
fix(security): P2 dashboard batch + P1-8 dead-code retraction (#50)
* fix(security): use constant-time compare for CSRF token verification ValidateCSRFToken compared the request-supplied token against the session-bound token with `!=`, which is byte-by-byte and exits on the first mismatch. The values are both 256-bit cryptographically random strings, so the practical exposure on a non-pathological network was low — but every other secret comparison in the codebase (the agent's API key, webhook HMAC) uses crypto/subtle.ConstantTimeCompare. CSRF should match. P2-1 from the 2026-05 security audit. * fix(security): add hard TTL on top of sliding session timeout The session expiration check used a single sliding-window timeout that ExtendSession refreshed on every request. A heavily-used account (notably admins with the dashboard open in a long-lived browser tab) could effectively never re-authenticate — a stolen session cookie was useful indefinitely. Adds a configurable absolute hard TTL alongside the existing sliding window. The new sessionStartKey ("session_start") is set ONCE at login and never touched by ExtendSession. GetUser now checks both: 1. Sliding idle timeout (existing behavior, sessionLoginKey). 2. Absolute hard TTL (new, sessionStartKey + absoluteTimeout). Configuration: - SESSION_ABSOLUTE_HOURS env var (defaults to 24h). - 0 disables the hard TTL — keeps the legacy sliding-only behavior for deployments that want it. - absoluteTimeout < timeout is rejected at NewManager time (would mean the hard TTL fires before the sliding window ever could; almost certainly a config error). The cookie's MaxAge now uses the larger of (sliding, absolute) so the browser drops the cookie no later than the server-side max — even if a later bug skipped server-side expiry. Older sessions that predate this commit don't carry sessionStartKey. GetUser treats those as if they just started (falls back to sessionLoginKey) — they hit the hard TTL one window later than fresh sessions, but the alternative is logging existing users out at deploy time. P2-3 from the 2026-05 security audit. * fix(security): emit Strict-Transport-Security + Permissions-Policy headers Two security-relevant headers were missing from the response from the SecurityHeaders middleware: - Strict-Transport-Security: tells the browser to never speak plain HTTP to this origin again for the next year (and to apply that to subdomains, and to be eligible for the HSTS preload list). Without it, a MITM-positioned attacker can force a downgrade on the very first visit after a TLS-cert-rotation reload, or after the user clicks an http:// link to the dashboard. The header is only honored over HTTPS, so emitting unconditionally is safe for dev :3000. - Permissions-Policy: explicitly disables camera, microphone, geolocation, USB, payment, and a few Chrome-specific tracking surfaces (interest- cohort, browsing-topics). None of these are used anywhere in the dashboard — so a future XSS or iframe-embedded asset can't silently ask the browser to enable them without a corresponding source change to this middleware. Both headers are best-practice defenses that pair with the existing CSP / X-Frame-Options / Referrer-Policy stack; cost is zero bytes of extra logic and a couple hundred bytes per response. Tests verify presence + the key tokens (max-age, includeSubDomains, preload for HSTS; geolocation, microphone, camera, usb, payment for Permissions-Policy). P2-6 from the 2026-05 security audit. * chore(security): remove dead Alpine.js dialog components (P1-8 retraction) components/dialog.templ declared three Alpine.js-based templates (Dialog, ConfirmDialog, AlertDialog) that used x-data / x-show / @click.away / @keydown.escape.window directives. The 2026-05 audit flagged this as broken on the assumption that Alpine wasn't loaded anywhere in the base layout — and the audit was correct about Alpine not being loaded, but WRONG about the implication. Closer reading: zero callers anywhere in the repo reference components.Dialog / components.ConfirmDialog / components.AlertDialog. The real modal infrastructure is two separate vanilla-JS systems: - framework/templates/layouts/base.templ:917+ — ConfirmDialog(), PromptDialog(), AlertDialog() templates wired to vanilla showConfirmDialog() / showPromptDialog() / showAlertDialog() functions. This is what every gear page actually calls. - framework/ui/modal.templ — Modal(id, title, size) + ConfirmModal for custom-content modals (notes-modal, delete-modal, etc.), also vanilla JS via closeModal(id). So components/dialog.templ is dead code: never invoked, but a latent trap because someone reading it might assume Alpine is in play, add it to base.templ, and break the CSP without realizing the file they were "making work" was never used. Drop the .templ source. The local _templ.go build artifact is already gitignored. Retracts the P1-8 misframing in docs/security-review/2026-05-findings.md (the doc still lives on PR #40; will be updated to reflect this deletion in a follow-up commit there). * fix(security): close hard-TTL gaps in legacy sessions + cookie MaxAge Copilot review on PR #50 caught two real holes in the initial P2-3 implementation (a25795c): 1. **Legacy sessions never anchored.** GetUser's fallback for sessions that pre-date sessionStartKey compared against sessionLoginKey — which ExtendSession refreshes on every request. So a legacy session that stayed warm via keepalive would slide indefinitely and never hit the absolute hard TTL. The whole point of the hard TTL. 2. **Cookie MaxAge re-sliding.** NewManager set the store-wide cookie MaxAge to the absolute timeout, but every Save (including ExtendSession's keepalive) re-emitted Set-Cookie with that same absolute value measured from "now" — so the browser's cookie expiry slid forward forever, not anchored to the session's original login. Fixes both in ExtendSession: - (1) When sessionStartKey is absent, capture the CURRENT sessionLoginKey as the anchor BEFORE overwriting it. Idempotent for already-anchored sessions. Legacy sessions get one full absoluteTimeout window from the next post-deploy ExtendSession call, then re-authenticate like everyone else. - (2) Per-save, override session.Options.MaxAge to min(sliding-timeout, remaining-absolute-TTL). Uses a copied Options struct so the override doesn't bleed into other sessions sharing the store. NewManager's store-wide MaxAge simplifies to the sliding timeout (which is what login gets when the absolute window is full). ExtendSession now also refuses to extend a session that's already past the absolute deadline, returning an explicit error — caller path then fails on the next GetUser, forcing re-auth. Two new tests: - TestManager_ExtendSession_AnchorsLegacySession: strip sessionStartKey from a real cookie, call ExtendSession, assert sessionStartKey is set in the result. - TestManager_ExtendSession_CookieMaxAgeShrinks: rewind sessionStartKey to 30m ago with 1h sliding + 1h absolute, call ExtendSession, assert the Set-Cookie MaxAge is ~1800 (remaining absolute) not 3600 (sliding). Refs: #50
1 parent c174f39 commit cf5b8f3

8 files changed

Lines changed: 428 additions & 197 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,21 @@ func main() {
150150
}
151151
}
152152

153-
// Calculate session timeout
153+
// Calculate session timeouts. The sliding window is refreshed on every
154+
// request; the absolute window is set ONCE at login and forces re-auth
155+
// once exceeded. See 2026-05 audit P2-3.
154156
sessionTimeout := time.Duration(cfg.SessionTimeoutMinutes) * time.Minute
157+
absoluteSessionTimeout := time.Duration(cfg.SessionAbsoluteHours) * time.Hour
155158

156159
// Initialize authentication manager with database
157-
authManager, err := auth.NewManager(db, cfg.SessionSecret, sessionTimeout, logger)
160+
authManager, err := auth.NewManager(db, cfg.SessionSecret, sessionTimeout, absoluteSessionTimeout, logger)
158161
if err != nil {
159162
log.Fatalf("Failed to create authentication manager: %v", err)
160163
}
161-
logger.Info("authentication manager initialized")
164+
logger.Info("authentication manager initialized",
165+
"sliding_timeout", sessionTimeout,
166+
"absolute_timeout", absoluteSessionTimeout,
167+
)
162168

163169
// Secure cookies are enabled by default; disable only when TLS is not configured
164170
if cfg.TLSCertPath == "" || cfg.TLSKeyPath == "" {

gearbox/internal/framework/auth/auth.go

Lines changed: 125 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"crypto/rand"
5+
"crypto/subtle"
56
"encoding/base64"
67
"fmt"
78
"log/slog"
@@ -18,6 +19,10 @@ const (
1819
sessionUserIDKey = "user_id"
1920
sessionTokenKey = "session_token" // OWASP 2026: Server-side session validation
2021
sessionLoginKey = "login_time"
22+
// sessionStartKey is the absolute session start timestamp. Set ONCE at
23+
// login and never touched by ExtendSession, so a heavily-used session
24+
// can still hit the hard TTL. See 2026-05 audit P2-3.
25+
sessionStartKey = "session_start"
2126
csrfTokenKey = "csrf_token"
2227
MaxFailedAttempts = 5
2328
LockoutDuration = 15 * time.Minute
@@ -27,17 +32,30 @@ const (
2732
type Manager struct {
2833
db *database.DB
2934
sessionStore *sessions.CookieStore
30-
timeout time.Duration
31-
logger *slog.Logger
35+
timeout time.Duration // sliding idle timeout (extended on activity)
36+
// absoluteTimeout is the hard upper bound on a session's lifetime,
37+
// measured from the original login (not extended on activity). A zero
38+
// value disables the hard TTL — only the sliding window applies. See
39+
// 2026-05 audit P2-3.
40+
absoluteTimeout time.Duration
41+
logger *slog.Logger
3242
}
3343

3444
// NewManager creates a new authentication manager.
35-
func NewManager(db *database.DB, sessionSecret string, timeout time.Duration, logger *slog.Logger) (*Manager, error) {
45+
// absoluteTimeout=0 disables the hard TTL.
46+
func NewManager(db *database.DB, sessionSecret string, timeout, absoluteTimeout time.Duration, logger *slog.Logger) (*Manager, error) {
3647
if len(sessionSecret) < 32 {
3748
return nil, fmt.Errorf("session secret must be at least 32 characters")
3849
}
50+
if absoluteTimeout > 0 && absoluteTimeout < timeout {
51+
return nil, fmt.Errorf("absoluteTimeout (%s) must be >= sliding timeout (%s)", absoluteTimeout, timeout)
52+
}
3953

40-
// Create cookie store
54+
// Create cookie store. The store-wide MaxAge is the sliding timeout —
55+
// this is the value used at Login when the session is fresh and the
56+
// hard TTL has its full window remaining. ExtendSession overrides
57+
// MaxAge per-save to min(sliding, remaining-absolute) so the browser
58+
// also drops the cookie at the hard TTL boundary as it approaches.
4159
store := sessions.NewCookieStore([]byte(sessionSecret))
4260
store.Options = &sessions.Options{
4361
Path: "/",
@@ -48,10 +66,11 @@ func NewManager(db *database.DB, sessionSecret string, timeout time.Duration, lo
4866
}
4967

5068
return &Manager{
51-
db: db,
52-
sessionStore: store,
53-
timeout: timeout,
54-
logger: logger,
69+
db: db,
70+
sessionStore: store,
71+
timeout: timeout,
72+
absoluteTimeout: absoluteTimeout,
73+
logger: logger,
5574
}, nil
5675
}
5776

@@ -131,10 +150,16 @@ func (m *Manager) Login(w http.ResponseWriter, r *http.Request, email, password
131150
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
132151
}
133152

134-
// Store user ID and session token in cookie
153+
// Store user ID and session token in cookie. sessionLoginKey is the
154+
// sliding-window timestamp that ExtendSession refreshes on each
155+
// request; sessionStartKey is the absolute-start timestamp that is
156+
// NEVER refreshed, so a long-active session still hits the hard TTL
157+
// (2026-05 audit P2-3).
158+
now := time.Now().Unix()
135159
session.Values[sessionUserIDKey] = user.ID
136160
session.Values[sessionTokenKey] = sessionToken // CRITICAL: Validated on every request
137-
session.Values[sessionLoginKey] = time.Now().Unix()
161+
session.Values[sessionLoginKey] = now
162+
session.Values[sessionStartKey] = now
138163
session.Values[csrfTokenKey] = csrfToken
139164

140165
// Save session
@@ -200,17 +225,35 @@ func (m *Manager) GetUser(r *http.Request) (*models.User, error) {
200225
return nil, fmt.Errorf("invalid session: missing token")
201226
}
202227

203-
// Check if session has expired (time-based)
228+
// Check sliding idle timeout: time since the most recent extend or login.
204229
loginTime, ok := session.Values[sessionLoginKey].(int64)
205230
if !ok {
206231
return nil, fmt.Errorf("invalid session")
207232
}
208-
209233
if time.Since(time.Unix(loginTime, 0)) > m.timeout {
210-
m.logger.Debug("session expired", "user_id", userID)
234+
m.logger.Debug("session expired (idle timeout)", "user_id", userID)
211235
return nil, fmt.Errorf("session expired")
212236
}
213237

238+
// Check absolute hard TTL: time since the original login. Independent of
239+
// activity, so a heavily-used session still has to re-auth once it hits
240+
// this cap. Zero disables this check (legacy sliding-only behavior).
241+
// See 2026-05 audit P2-3.
242+
if m.absoluteTimeout > 0 {
243+
startUnix, ok := session.Values[sessionStartKey].(int64)
244+
if !ok {
245+
// Older sessions written before this field existed don't have
246+
// sessionStartKey. Treat them as if they just started — they'll
247+
// hit the hard TTL one window later than fresh sessions, but
248+
// the alternative is logging existing users out at deploy time.
249+
startUnix = loginTime
250+
}
251+
if time.Since(time.Unix(startUnix, 0)) > m.absoluteTimeout {
252+
m.logger.Debug("session expired (absolute timeout)", "user_id", userID)
253+
return nil, fmt.Errorf("session expired")
254+
}
255+
}
256+
214257
// Get user from database
215258
user, err := m.db.GetUserByID(userID)
216259
if err != nil {
@@ -278,7 +321,13 @@ func (m *Manager) ValidateCSRFToken(r *http.Request) error {
278321
return fmt.Errorf("no CSRF token in request")
279322
}
280323

281-
if requestToken != sessionToken {
324+
// Constant-time compare so that a network observer can't recover the
325+
// session-bound CSRF token byte by byte from response-timing variance.
326+
// The values are both 256-bit randoms generated via crypto/rand, so the
327+
// practical risk on a non-pathological network was always low — but
328+
// every other secret comparison in this codebase uses subtle, and CSRF
329+
// should match (2026-05 audit P2-1).
330+
if subtle.ConstantTimeCompare([]byte(requestToken), []byte(sessionToken)) != 1 {
282331
return fmt.Errorf("CSRF token mismatch")
283332
}
284333

@@ -292,6 +341,27 @@ func (m *Manager) IsAuthenticated(r *http.Request) bool {
292341
}
293342

294343
// ExtendSession refreshes the session login time to prevent timeout.
344+
//
345+
// Two extra behaviors on top of the simple "bump sessionLoginKey" path,
346+
// both addressing follow-up review on the 2026-05 audit P2-3 fix:
347+
//
348+
// 1. **Anchor legacy sessions.** If a session predates this commit it
349+
// won't have sessionStartKey set. Without anchoring, GetUser's fallback
350+
// would compare against the freshly-refreshed sessionLoginKey forever
351+
// and the absolute hard TTL would never fire. We capture the CURRENT
352+
// (pre-refresh) sessionLoginKey as the anchor, giving the legacy
353+
// session one full absoluteTimeout window from now — then it
354+
// re-authenticates like everyone else.
355+
//
356+
// 2. **Shrink the cookie MaxAge as the hard TTL approaches.** Every Save
357+
// re-emits the Set-Cookie header. If we kept the store's default
358+
// MaxAge, the browser would see "this cookie is good for sliding-
359+
// timeout from right now" on every request, and the cookie would
360+
// never reflect the server-side hard TTL. Per-save we set the cookie
361+
// MaxAge to min(sliding-timeout, remaining-absolute-TTL) so the
362+
// browser ALSO drops the cookie at the absolute boundary. The
363+
// override goes on a copied Options struct so we don't mutate the
364+
// shared store-wide options.
295365
func (m *Manager) ExtendSession(w http.ResponseWriter, r *http.Request) error {
296366
session, err := m.sessionStore.Get(r, sessionName)
297367
if err != nil {
@@ -304,7 +374,39 @@ func (m *Manager) ExtendSession(w http.ResponseWriter, r *http.Request) error {
304374
return fmt.Errorf("not authenticated")
305375
}
306376

307-
// Update login time to now
377+
// (1) Anchor legacy sessions. Set sessionStartKey to the CURRENT
378+
// sessionLoginKey before we overwrite it below. Idempotent for
379+
// already-anchored sessions (we only set it when absent).
380+
if _, anchored := session.Values[sessionStartKey].(int64); !anchored {
381+
if loginTime, ok := session.Values[sessionLoginKey].(int64); ok {
382+
session.Values[sessionStartKey] = loginTime
383+
}
384+
}
385+
386+
// (2) Override per-save MaxAge to track the hard TTL. Default is the
387+
// sliding timeout; if we're inside the absolute TTL window, use
388+
// whichever is smaller.
389+
if m.absoluteTimeout > 0 {
390+
if startUnix, ok := session.Values[sessionStartKey].(int64); ok {
391+
remaining := time.Until(time.Unix(startUnix, 0).Add(m.absoluteTimeout))
392+
if remaining <= 0 {
393+
// Past the hard TTL — don't extend. Caller path will fail
394+
// on the next GetUser and force re-auth.
395+
return fmt.Errorf("session past absolute timeout")
396+
}
397+
maxAge := m.timeout
398+
if remaining < maxAge {
399+
maxAge = remaining
400+
}
401+
// Copy Options so we don't mutate the shared store struct
402+
// (which is the cookie-default applied to every other session).
403+
opts := *m.sessionStore.Options
404+
opts.MaxAge = int(maxAge.Seconds())
405+
session.Options = &opts
406+
}
407+
}
408+
409+
// Update login time to now (slides the idle window forward).
308410
session.Values[sessionLoginKey] = time.Now().Unix()
309411

310412
return session.Save(r, w)
@@ -561,10 +663,16 @@ func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, u
561663
return fmt.Errorf("failed to generate CSRF token: %w", err)
562664
}
563665

564-
// Store user ID and session token in cookie
666+
// Store user ID and session token in cookie. sessionLoginKey is the
667+
// sliding-window timestamp that ExtendSession refreshes on each
668+
// request; sessionStartKey is the absolute-start timestamp that is
669+
// NEVER refreshed, so a long-active session still hits the hard TTL
670+
// (2026-05 audit P2-3).
671+
now := time.Now().Unix()
565672
session.Values[sessionUserIDKey] = user.ID
566673
session.Values[sessionTokenKey] = sessionToken // CRITICAL: Validated on every request
567-
session.Values[sessionLoginKey] = time.Now().Unix()
674+
session.Values[sessionLoginKey] = now
675+
session.Values[sessionStartKey] = now
568676
session.Values[csrfTokenKey] = csrfToken
569677

570678
// Save session

0 commit comments

Comments
 (0)