Address Gadfly review on #4: TOCTOU, sliding cookie, CSRF, argon2
Build image / build-and-push (push) Successful in 5s
Build image / build-and-push (push) Successful in 5s
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive): Security / correctness - Race-free registration: is_admin and the registration gate are now computed atomically inside a single INSERT...SELECT, so concurrent first registrations can't both become admin or bypass closed registration (fixed the whole TOCTOU cluster). - Sliding session now reaches the browser: ResolveSession returns the current expiry and requireAuth re-sets the cookie, so active users aren't logged out 30 days after login regardless of activity. - Login CSRF: csrfGuard rejects state-changing requests whose Origin doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is unaffected). SameSite=Lax alone didn't cover this. - argon2id tuned to RFC 9106's second recommended profile (t=3). - Timing equalizer can't fail open: the dummy hash is derived deterministically (fixed salt, no RNG) so it's always present. - Password length (<=1024) enforced in the service for both register and login, not just HTTP binding tags; login rejects over-long input before spending argon2 work. Error handling / robustness - Login logs a malformed stored hash instead of silently treating it as a wrong password. - Best-effort session writes (Touch/Delete during renewal, expiry, and corrupt-expiry cleanup) now log on failure. - index sessions.expires_at via new migration 0002 (0001 is immutable). Maintainability - Extract startSessionAndRespond and abortUnauthenticated; make writeServiceError a free function; consistent error handling in decodeHash; doc/comment fixes. Tests: over-long password, CSRF guard (cross-origin/same-origin/dev no-op), and cookie refresh on authenticated requests; migration-version assertions bumped to 2. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
+18
-19
@@ -1,9 +1,11 @@
|
||||
// Package service is pansy's business-logic seam: every operation is a method on
|
||||
// *Service taking (ctx, actor, args), and all permission checks and invariants
|
||||
// live here rather than in the HTTP handlers. REST handlers (internal/api) and,
|
||||
// later, agent tools (internal/agent) are thin adapters over these methods, so
|
||||
// both inherit the same rules. This file holds the shared plumbing; feature
|
||||
// methods live alongside it (auth.go, and gardens/objects/… in later issues).
|
||||
// Package service is pansy's business-logic seam: all permission checks and
|
||||
// invariants live here rather than in the HTTP handlers. Resource operations
|
||||
// take (ctx, actor, args) so every rule is enforced regardless of caller; the
|
||||
// auth operations here are the exception — they establish the actor, so they
|
||||
// take credentials rather than one. REST handlers (internal/api) and, later,
|
||||
// agent tools (internal/agent) are thin adapters over these methods, so both
|
||||
// inherit the same rules. This file holds the shared plumbing; feature methods
|
||||
// live alongside it (auth.go, and gardens/objects/… in later issues).
|
||||
package service
|
||||
|
||||
import (
|
||||
@@ -12,7 +14,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
@@ -33,23 +34,21 @@ type Service struct {
|
||||
cfg *config.Config
|
||||
// now is the clock, injectable so tests can advance time (session expiry).
|
||||
now func() time.Time
|
||||
// dummyHash is a valid argon2id hash of a throwaway password. Login verifies
|
||||
// against it when an email is unknown so the response time doesn't reveal
|
||||
// whether an account exists.
|
||||
// dummyHash is a valid argon2id hash Login verifies against when an email is
|
||||
// unknown, so response time doesn't reveal whether an account exists. It is
|
||||
// produced by timingHash (fixed salt, no RNG) so it is always present — an
|
||||
// empty one would silently re-open account enumeration.
|
||||
dummyHash string
|
||||
}
|
||||
|
||||
// New constructs a Service. It precomputes a dummy password hash used to
|
||||
// equalize login timing; if that fails (it shouldn't), login still works but
|
||||
// loses the timing defense.
|
||||
// New constructs a Service.
|
||||
func New(st *store.DB, cfg *config.Config) *Service {
|
||||
s := &Service{store: st, cfg: cfg, now: time.Now}
|
||||
if h, err := hashPassword("pansy-timing-equalizer-not-a-real-password"); err != nil {
|
||||
slog.Warn("service: could not precompute login timing hash", "error", err)
|
||||
} else {
|
||||
s.dummyHash = h
|
||||
return &Service{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
dummyHash: timingHash(),
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatTime renders a time as pansy's canonical UTC string.
|
||||
|
||||
Reference in New Issue
Block a user