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.
|
email is present.
|
||||||
- **Authorization gate:** the user MUST be a member of `REQUIRED_GROUP` (exact,
|
- **Authorization gate:** the user MUST be a member of `REQUIRED_GROUP` (exact,
|
||||||
case-sensitive match). Otherwise the callback returns **403**.
|
case-sensitive match). Otherwise the callback returns **403**.
|
||||||
- On success a `hsa_session` cookie is set for **12 hours**. Cookies are
|
- On success a `hsa_session` cookie is set for **12 hours**. The session is an
|
||||||
`HttpOnly`, `SameSite=Lax`, and `Secure` whenever `REDIRECT_URL` is `https://`.
|
**AES-256-GCM** sealed token (tamper-evident; key = `SHA-256(SESSION_SECRET)`),
|
||||||
The session is an AES-encrypted token; the key is `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`.
|
- `GET /logout` clears the session and redirects to `/login`.
|
||||||
- **Public routes** (no session required): `/healthz`, `/static/*`, `/login`,
|
- **Public routes** (no session required): `/healthz`, `/static/*`, `/login`,
|
||||||
`/callback`, `/logout`. **Every other route requires a valid session**; missing
|
`/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
|
- `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
|
**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"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
|
// 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) {
|
func TestAI_NotesCRUD(t *testing.T) {
|
||||||
s := testServerWithStore(t)
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
return
|
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)
|
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"maisym.com/hsa/internal/auth"
|
"maisym.com/hsa/internal/auth"
|
||||||
"maisym.com/hsa/internal/config"
|
"maisym.com/hsa/internal/config"
|
||||||
|
|
@ -39,7 +40,7 @@ func testServerWithStore(t *testing.T) *Server {
|
||||||
|
|
||||||
func authCookie(t *testing.T, s *Server) *http.Cookie {
|
func authCookie(t *testing.T, s *Server) *http.Cookie {
|
||||||
t.Helper()
|
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)
|
v, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,7 @@ func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", rec.MimeType)
|
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))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename))
|
||||||
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
|
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
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", a.MimeType)
|
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))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
|
||||||
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
|
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ import (
|
||||||
const (
|
const (
|
||||||
sessionCookie = "hsa_session"
|
sessionCookie = "hsa_session"
|
||||||
loginCookie = "hsa_login"
|
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.
|
// 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)
|
s.serverError(w, "encode session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setCookie(w, sessionCookie, encoded, 12*time.Hour)
|
s.setCookie(w, sessionCookie, encoded, sessionTTL)
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"maisym.com/hsa/internal/auth"
|
"maisym.com/hsa/internal/auth"
|
||||||
"maisym.com/hsa/internal/config"
|
"maisym.com/hsa/internal/config"
|
||||||
|
|
@ -64,7 +65,7 @@ func TestHome_NoSession_RedirectsToLogin(t *testing.T) {
|
||||||
|
|
||||||
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
|
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
|
||||||
s := testServerWithStore(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)
|
cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue