hsa-app/internal/web/middleware.go
Jean-Michel Tremblay 866bc175fb
All checks were successful
Build and Test / build-and-test (push) Successful in 38s
Security hardening: server-side session expiry + nosniff on files
1. Enforce the 12h session lifetime server-side in requireAuth (reject a
   session older than the TTL even if the sealed cookie is intact), so a
   leaked cookie value can't be replayed indefinitely. Shared sessionTTL
   const drives both the cookie MaxAge and the check.
2. Send X-Content-Type-Options: nosniff when serving user-uploaded
   receipt/attachment bytes, so the browser won't sniff past the declared
   (upload-time allowlisted) MIME type.

Update SPEC §2 and §6 accordingly; tests cover stale-session rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:56:38 -04:00

70 lines
1.8 KiB
Go

package web
import (
"context"
"net/http"
"time"
"maisym.com/hsa/internal/auth"
)
type ctxKey int
const sessionKey ctxKey = 0
// requireAuth rejects requests without a valid session cookie by redirecting to /login.
// On success the decoded session is placed in the request context.
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
sess, err := auth.DecodeSession(c.Value, s.cfg.SessionKey)
if err != nil {
s.clearCookie(w, sessionCookie)
http.Redirect(w, r, "/login", http.StatusFound)
return
}
// Server-side expiry: a cookie's MaxAge is client-enforced, so also reject a
// session older than its TTL — a captured cookie value can't be replayed forever.
if time.Since(sess.IssuedAt) > sessionTTL {
s.clearCookie(w, sessionCookie)
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), sessionKey, sess)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionFrom returns the session stored in the context by requireAuth.
func sessionFrom(ctx context.Context) auth.Session {
sess, _ := ctx.Value(sessionKey).(auth.Session)
return sess
}
func (s *Server) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: s.cfg.SecureCookies,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.cfg.SecureCookies,
SameSite: http.SameSiteLaxMode,
})
}