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:
+70
-26
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,7 +31,7 @@ type registerRequest struct {
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Password string `json:"password" binding:"required,max=1024"`
|
||||
}
|
||||
|
||||
// register creates a local account and logs it in (sets the session cookie).
|
||||
@@ -47,15 +48,11 @@ func (h *handlers) register(c *gin.Context) {
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeServiceError(c, err)
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.startSession(c, user.ID); err != nil {
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
h.startSessionAndRespond(c, user)
|
||||
}
|
||||
|
||||
// login verifies credentials and sets the session cookie.
|
||||
@@ -68,15 +65,11 @@ func (h *handlers) login(c *gin.Context) {
|
||||
|
||||
user, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
h.writeServiceError(c, err)
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.startSession(c, user.ID); err != nil {
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
h.startSessionAndRespond(c, user)
|
||||
}
|
||||
|
||||
// logout deletes the current session (if any) and clears the cookie. It is
|
||||
@@ -102,37 +95,84 @@ func (h *handlers) providers(c *gin.Context) {
|
||||
}
|
||||
|
||||
// requireAuth is middleware that rejects requests without a valid session and,
|
||||
// on success, stashes the resolved user in the context. Feature routers added by
|
||||
// later issues (gardens, objects, …) attach this to their protected groups.
|
||||
// on success, stashes the resolved user in the context and slides the cookie's
|
||||
// lifetime forward in step with the server-side session (otherwise an active
|
||||
// user would be logged out 30 days after login regardless of activity). Feature
|
||||
// routers added by later issues (gardens, objects, …) attach this to their
|
||||
// protected groups.
|
||||
func (h *handlers) requireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, err := c.Cookie(sessionCookie)
|
||||
if err != nil || token == "" {
|
||||
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required")
|
||||
c.Abort()
|
||||
abortUnauthenticated(c)
|
||||
return
|
||||
}
|
||||
user, err := h.svc.ResolveSession(c.Request.Context(), token)
|
||||
user, expiresAt, err := h.svc.ResolveSession(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
// Any resolution failure (missing/expired/tampered) is 401, never 404:
|
||||
// the client should log in, not think a resource is gone.
|
||||
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required")
|
||||
c.Abort()
|
||||
abortUnauthenticated(c)
|
||||
return
|
||||
}
|
||||
c.Set(actorKey, user)
|
||||
// Refresh the client cookie to the session's current expiry so browser and
|
||||
// server slide together.
|
||||
h.setSessionCookie(c, token, expiresAt)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// startSession issues a session and writes the cookie.
|
||||
func (h *handlers) startSession(c *gin.Context, userID int64) error {
|
||||
token, expiresAt, err := h.svc.CreateSession(c.Request.Context(), userID)
|
||||
// abortUnauthenticated writes the standard 401 and stops the handler chain.
|
||||
func abortUnauthenticated(c *gin.Context) {
|
||||
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required")
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// csrfGuard rejects state-changing requests whose Origin header doesn't match
|
||||
// the instance's own origin — a defense against login/logout CSRF, which
|
||||
// SameSite=Lax cookies alone do not prevent. It is a no-op unless PANSY_BASE_URL
|
||||
// is set (so it never interferes with the local Vite dev proxy), and it allows
|
||||
// requests with no Origin (non-browser clients like curl, which aren't a CSRF
|
||||
// vector). Safe methods are always allowed.
|
||||
func (h *handlers) csrfGuard() gin.HandlerFunc {
|
||||
var wantHost string
|
||||
if u, err := url.Parse(h.cfg.BaseURL); err == nil {
|
||||
wantHost = u.Host
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if wantHost == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if origin := c.GetHeader("Origin"); origin != "" {
|
||||
if u, err := url.Parse(origin); err != nil || !strings.EqualFold(u.Host, wantHost) {
|
||||
writeAPIError(c, http.StatusForbidden, "CROSS_ORIGIN", "cross-origin request rejected")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// startSessionAndRespond issues a session, sets the cookie, and returns the user
|
||||
// as JSON — the shared tail of register and login.
|
||||
func (h *handlers) startSessionAndRespond(c *gin.Context, user *domain.User) {
|
||||
token, expiresAt, err := h.svc.CreateSession(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
// The account exists and is usable via login; only the auto-login cookie
|
||||
// failed. Log it so the operator can see the underlying DB problem.
|
||||
slog.Error("api: could not start session", "user_id", user.ID, "error", err)
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
|
||||
return
|
||||
}
|
||||
h.setSessionCookie(c, token, expiresAt)
|
||||
return nil
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// cookieSecure reports whether the session cookie should carry the Secure
|
||||
@@ -143,6 +183,10 @@ func (h *handlers) cookieSecure() bool {
|
||||
}
|
||||
|
||||
func (h *handlers) setSessionCookie(c *gin.Context, token string, expiresAt time.Time) {
|
||||
// Callers only ever pass a future expiry (login/register set now+TTL;
|
||||
// requireAuth uses a session already checked to be unexpired), so this is
|
||||
// always well above the floor; the floor merely avoids emitting a delete
|
||||
// cookie (MaxAge<=0) from a pathological clock skew.
|
||||
maxAge := max(int(time.Until(expiresAt).Seconds()), 1)
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(sessionCookie, token, maxAge, "/", "", h.cookieSecure(), true)
|
||||
@@ -161,7 +205,7 @@ func mustActor(c *gin.Context) *domain.User {
|
||||
|
||||
// writeServiceError maps a service-layer sentinel error to pansy's JSON error
|
||||
// envelope. Login failures never leak which of email/password was wrong.
|
||||
func (h *handlers) writeServiceError(c *gin.Context, err error) {
|
||||
func writeServiceError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalidCredentials):
|
||||
writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password")
|
||||
|
||||
Reference in New Issue
Block a user