Security hardening: server-side session expiry + nosniff on files
All checks were successful
Build and Test / build-and-test (push) Successful in 38s

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>
This commit is contained in:
Jean-Michel Tremblay 2026-06-20 20:56:38 -04:00
parent c930f715b7
commit 866bc175fb
7 changed files with 49 additions and 7 deletions

13
SPEC.md
View file

@ -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).
---

View file

@ -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)

View file

@ -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))
})

View file

@ -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)

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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)