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:
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -98,6 +99,20 @@ func TestRegistrationClosedAllowsBootstrapThenBlocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsOverlongPassword(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
long := strings.Repeat("a", maxPasswordLen+1)
|
||||
|
||||
if _, err := s.Register(context.Background(), RegisterInput{Email: "[email protected]", DisplayName: "A", Password: long}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("register overlong err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
mustRegister(t, s, "[email protected]", "Bob", "password123")
|
||||
if _, err := s.Login(context.Background(), "[email protected]", long); !errors.Is(err, domain.ErrInvalidCredentials) {
|
||||
t.Errorf("login overlong err = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAuthDisabledRejectsRegisterAndLogin(t *testing.T) {
|
||||
cfg := openConfig()
|
||||
cfg.LocalAuth = false
|
||||
@@ -139,7 +154,7 @@ func TestLoginRejectsOIDCOnlyUser(t *testing.T) {
|
||||
iss, sub := "https://idp.example", "subject-1"
|
||||
if _, err := s.store.CreateUser(context.Background(), &domain.User{
|
||||
Email: "[email protected]", DisplayName: "O", OIDCIssuer: &iss, OIDCSubject: &sub,
|
||||
}); err != nil {
|
||||
}, true); err != nil {
|
||||
t.Fatalf("seed oidc user: %v", err)
|
||||
}
|
||||
if _, err := s.Login(context.Background(), "[email protected]", "anything"); !errors.Is(err, domain.ErrInvalidCredentials) {
|
||||
@@ -156,29 +171,32 @@ func TestSessionLifecycle(t *testing.T) {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.ResolveSession(context.Background(), token)
|
||||
got, exp, err := s.ResolveSession(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSession: %v", err)
|
||||
}
|
||||
if got.ID != u.ID {
|
||||
t.Errorf("resolved user %d, want %d", got.ID, u.ID)
|
||||
}
|
||||
if !exp.After(s.now()) {
|
||||
t.Errorf("resolved expiry %v is not in the future", exp)
|
||||
}
|
||||
|
||||
// Logout invalidates it.
|
||||
if err := s.Logout(context.Background(), token); err != nil {
|
||||
t.Fatalf("Logout: %v", err)
|
||||
}
|
||||
if _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
|
||||
if _, _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("resolve after logout err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionRejectsGarbageToken(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
if _, err := s.ResolveSession(context.Background(), "not-a-real-token"); !errors.Is(err, domain.ErrNotFound) {
|
||||
if _, _, err := s.ResolveSession(context.Background(), "not-a-real-token"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("garbage token err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := s.ResolveSession(context.Background(), ""); !errors.Is(err, domain.ErrNotFound) {
|
||||
if _, _, err := s.ResolveSession(context.Background(), ""); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("empty token err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +215,7 @@ func TestSessionExpiryAndLazyDeletion(t *testing.T) {
|
||||
|
||||
// Jump past the 30-day TTL: the session must be treated as gone...
|
||||
s.now = func() time.Time { return base.Add(sessionTTL + time.Hour) }
|
||||
if _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
|
||||
if _, _, err := s.ResolveSession(context.Background(), token); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("expired resolve err = %v, want ErrNotFound", err)
|
||||
}
|
||||
// ...and lazily deleted from the store.
|
||||
@@ -219,9 +237,13 @@ func TestSessionSlidingRenewal(t *testing.T) {
|
||||
|
||||
// Use it 10 days later; expiry should slide forward.
|
||||
s.now = func() time.Time { return base.Add(10 * 24 * time.Hour) }
|
||||
if _, err := s.ResolveSession(context.Background(), token); err != nil {
|
||||
_, resolvedExp, err := s.ResolveSession(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSession: %v", err)
|
||||
}
|
||||
if !resolvedExp.After(firstExp) {
|
||||
t.Errorf("returned expiry did not slide: %v not after %v", resolvedExp, firstExp)
|
||||
}
|
||||
sess, err := s.store.GetSession(context.Background(), hashToken(token))
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user