diff --git a/SPEC.md b/SPEC.md index 9c95dc1..67df7b2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -37,9 +37,12 @@ editing, or general reporting beyond the Tally view. email is present. - **Authorization gate:** the user MUST be a member of `REQUIRED_GROUP` (exact, case-sensitive match). Otherwise the callback returns **403**. -- On success a `hsa_session` cookie is set for **12 hours**. Cookies are - `HttpOnly`, `SameSite=Lax`, and `Secure` whenever `REDIRECT_URL` is `https://`. - The session is an AES-encrypted token; the key is `SHA-256(SESSION_SECRET)`. +- On success a `hsa_session` cookie is set for **12 hours**. The session is an + **AES-256-GCM** sealed token (tamper-evident; key = `SHA-256(SESSION_SECRET)`), + and the 12h lifetime is enforced **server-side** (a session older than the TTL is + rejected even if the cookie value is intact), not just via the cookie's MaxAge. + Cookies are `HttpOnly`, `SameSite=Lax`, and `Secure` whenever `REDIRECT_URL` is + `https://`. - `GET /logout` clears the session and redirects to `/login`. - **Public routes** (no session required): `/healthz`, `/static/*`, `/login`, `/callback`, `/logout`. **Every other route requires a valid session**; missing @@ -203,7 +206,9 @@ filename, attachment count, and tags, with links to add another / Manage / Expor - `GET /receipt/{id}/file` and `GET /attachment/{id}/file` serve the stored bytes **from the DB blob** (so serving works even if the on-disk copy is gone), with - the original MIME type and `Content-Disposition: inline`. + the original MIME type, `Content-Disposition: inline`, and + `X-Content-Type-Options: nosniff` (so the browser won't sniff user bytes past the + declared, upload-time-allowlisted type). --- diff --git a/internal/web/ai_test.go b/internal/web/ai_test.go index d9790f9..bb375ef 100644 --- a/internal/web/ai_test.go +++ b/internal/web/ai_test.go @@ -7,6 +7,9 @@ import ( "strconv" "strings" "testing" + "time" + + "maisym.com/hsa/internal/auth" ) // uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob. @@ -85,6 +88,25 @@ func TestAI_NoMissWhenSuggestionMatches(t *testing.T) { } } +func TestAuth_StaleSessionRedirects(t *testing.T) { + s := testServerWithStore(t) + // A session issued well beyond the TTL must be rejected server-side even though + // the cookie value itself is a valid (untampered) sealed token. + old := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, + IssuedAt: time.Now().Add(-13 * time.Hour)} + v, err := auth.EncodeSession(old, s.cfg.SessionKey) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: v}) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" { + t.Errorf("stale session: status=%d location=%q, want 302 -> /login", rec.Code, rec.Header().Get("Location")) + } +} + func TestAI_NotesCRUD(t *testing.T) { s := testServerWithStore(t) diff --git a/internal/web/middleware.go b/internal/web/middleware.go index feb6faa..b12aff1 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -27,6 +27,13 @@ func (s *Server) requireAuth(next http.Handler) http.Handler { 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)) }) diff --git a/internal/web/upload_test.go b/internal/web/upload_test.go index bff55ab..4c445e2 100644 --- a/internal/web/upload_test.go +++ b/internal/web/upload_test.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "testing" + "time" "maisym.com/hsa/internal/auth" "maisym.com/hsa/internal/config" @@ -39,7 +40,7 @@ func testServerWithStore(t *testing.T) *Server { func authCookie(t *testing.T, s *Server) *http.Cookie { t.Helper() - sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}} + sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()} v, err := auth.EncodeSession(sess, s.cfg.SessionKey) if err != nil { t.Fatal(err) diff --git a/internal/web/views.go b/internal/web/views.go index 7be742a..57a395b 100644 --- a/internal/web/views.go +++ b/internal/web/views.go @@ -215,6 +215,7 @@ func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", rec.MimeType) + w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename)) http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData)) } @@ -228,6 +229,7 @@ func (s *Server) handleAttachmentFile(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", a.MimeType) + w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename)) http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData)) } diff --git a/internal/web/web.go b/internal/web/web.go index b1a4a9c..833efff 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -19,6 +19,10 @@ import ( const ( sessionCookie = "hsa_session" loginCookie = "hsa_login" + + // sessionTTL bounds a session both as the cookie's MaxAge and as a server-side + // age check (see requireAuth), so a leaked cookie value can't be replayed forever. + sessionTTL = 12 * time.Hour ) // Server holds the wired dependencies for the HTTP handlers. @@ -224,7 +228,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { s.serverError(w, "encode session", err) return } - s.setCookie(w, sessionCookie, encoded, 12*time.Hour) + s.setCookie(w, sessionCookie, encoded, sessionTTL) http.Redirect(w, r, "/", http.StatusFound) } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index e972600..13ef0bd 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "maisym.com/hsa/internal/auth" "maisym.com/hsa/internal/config" @@ -64,7 +65,7 @@ func TestHome_NoSession_RedirectsToLogin(t *testing.T) { func TestHome_ValidSession_ShowsUploadForm(t *testing.T) { s := testServerWithStore(t) - sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}} + sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()} cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey) if err != nil { t.Fatal(err)