Security hardening: server-side session expiry + nosniff on files
All checks were successful
Build and Test / build-and-test (push) Successful in 38s
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:
parent
c930f715b7
commit
866bc175fb
7 changed files with 49 additions and 7 deletions
13
SPEC.md
13
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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue