fix: set Secure/HttpOnly/SameSite on session cookies (closes #47) (#48)
All checks were successful
check / check (push) Successful in 5s
All checks were successful
check / check (push) Successful in 5s
closes #47 Fixes the two remaining `gosec` findings on `main`, both `G124` (http.Cookie missing or has insecure `Secure`, `HttpOnly`, or `SameSite` attribute): - `internal/session/session.go:84` (`CreateSession`, the login set-cookie path) - `internal/session/session.go:128` (`ClearSession`, the logout delete-cookie path) ## What changed - Both cookie-writing paths now unconditionally set `Secure: true`, `HttpOnly: true`, and `SameSite: http.SameSiteStrictMode`. - The `secure` field (previously wired to `!config.Debug`) and the `sameSite` field are removed from `session.Manager`, and the dead secure-toggle parameter is removed from `session.NewManager`, which now takes only the signing key (reviewer-directed; the mechanical call-shape updates in `session_test.go` leave every assertion untouched). - TDD per repo rules: the first commit adds `TestSessionCookieAttributesAlwaysSecure` (failing), asserting that every cookie emitted by the session manager carries `HttpOnly`, `Secure`, and `SameSite` of Lax or stricter, for both write paths. The second commit makes it pass. - `TODO.md` updated per its Workflow section (Next Step completed, next Future Step promoted, stale "10 open findings" Status text corrected). ## Attribute choices and reasoning - `Secure: true` always: the `G124` analyzer only accepts a constant `true` store, and there is no legitimate configuration in which the authentication cookie should be sent over plaintext HTTP. The old behavior disabled `Secure` whenever `debug` was on. Local development over `http://localhost` keeps working: browsers treat `localhost` as a trustworthy origin and accept `Secure` cookies there. Any plain-HTTP flow on a non-localhost host will no longer keep a session, which is the point of the fix. - `SameSite: Strict` (unchanged from current production behavior, and stricter than the Lax minimum): the login form is a same-origin POST to `/` followed by a same-site redirect, so `Strict` breaks nothing. - `HttpOnly: true` (unchanged). ## Verification `make check` (tests, golangci-lint, fmt-check) is fully green on the branch head `cb9e14e`: all tests pass and the linter reports 0 issues, independently confirmed by the reviewer in a fresh worktree. Commit history: `ca15f52` (failing test) → `02ca16a` (fix + TODO.md, closes #47) → `cb9e14e` (drop the dead `NewManager` parameter). Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #48 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #48.
This commit is contained in:
30
TODO.md
30
TODO.md
@@ -10,22 +10,27 @@
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0. No git tags exist. main (6b4a1d7, 2026-04-07) is current with
|
||||
origin; recent work extracted the internal/magic and internal/allowlist
|
||||
packages. The 10 gosec findings from the 2026-07-06 morning survey are
|
||||
NOT fixed on main: the newest gosec/lint fix commits are from
|
||||
2026-02-25 (85729d9, ce6db76) and nothing since touches gosec, so those
|
||||
findings remain open.
|
||||
pre-1.0. No git tags exist. Recent work extracted the internal/magic,
|
||||
internal/allowlist, internal/httpfetcher, and internal/signature
|
||||
packages. The gosec findings from the 2026-07-06 survey are resolved:
|
||||
the last two open findings (G124, session cookie attributes in
|
||||
internal/session) are fixed as of this change, so `make check` is green
|
||||
on main.
|
||||
|
||||
# Next Step
|
||||
|
||||
Fix the 10 open gosec lint findings so make check passes on main again
|
||||
(main must always be green; no gosec fix commits have landed since
|
||||
2026-02-25). No blanket suppressions; fix or justify each finding
|
||||
individually.
|
||||
P0: manual test pass of the auth and encrypted URL flows, then commit
|
||||
the checked-off results to TODO.md: visit / and see the login form;
|
||||
wrong key shows an error; correct signing key shows the generator form;
|
||||
a generated encrypted URL serves the image; an expired URL (short TTL)
|
||||
returns 410; logout redirects back to login
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-07 fix the two remaining gosec findings (G124 in
|
||||
internal/session): session cookies now always carry
|
||||
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
|
||||
`make check` green (closes #47)
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||
Makefile shims, README Entrypoints section
|
||||
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
||||
@@ -48,11 +53,6 @@ individually.
|
||||
|
||||
# Future Steps
|
||||
|
||||
- P0: manual test pass of the auth and encrypted URL flows, then
|
||||
commit the checked-off results to TODO.md: visit / and see the login
|
||||
form; wrong key shows an error; correct signing key shows the
|
||||
generator form; a generated encrypted URL serves the image; an
|
||||
expired URL (short TTL) returns 410; logout redirects back to login
|
||||
- P0: implement cache size management and eviction so the disk cannot
|
||||
fill up
|
||||
- P0: validate configuration on startup, fail fast on bad config
|
||||
|
||||
@@ -94,8 +94,9 @@ func (s *Handlers) initImageService() error {
|
||||
s.imgSvc = svc
|
||||
s.log.Info("image service initialized")
|
||||
|
||||
// Initialize session manager (signing key is validated at config load time)
|
||||
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
|
||||
// Initialize session manager (signing key is validated at config load
|
||||
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
||||
sessMgr, err := session.NewManager(s.config.SigningKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -37,13 +37,15 @@ type Data struct {
|
||||
// Manager handles session creation and validation using encrypted cookies.
|
||||
type Manager struct {
|
||||
sc *securecookie.SecureCookie
|
||||
secure bool // Set Secure flag on cookies (should be true in production)
|
||||
sameSite http.SameSite
|
||||
}
|
||||
|
||||
// NewManager creates a session manager with keys derived from the signing key.
|
||||
// Set secure=true in production to require HTTPS for cookies.
|
||||
func NewManager(signingKey string, secure bool) (*Manager, error) {
|
||||
//
|
||||
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
|
||||
// attributes; this cannot be configured. Browsers treat http://localhost as a
|
||||
// trustworthy origin and accept Secure cookies there, so local development
|
||||
// keeps working.
|
||||
func NewManager(signingKey string) (*Manager, error) {
|
||||
masterKey := []byte(signingKey)
|
||||
|
||||
// Derive separate keys for HMAC (hash) and encryption (block)
|
||||
@@ -62,8 +64,6 @@ func NewManager(signingKey string, secure bool) (*Manager, error) {
|
||||
|
||||
return &Manager{
|
||||
sc: sc,
|
||||
secure: secure,
|
||||
sameSite: http.SameSiteStrictMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error {
|
||||
Path: "/",
|
||||
MaxAge: int(SessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: m.secure,
|
||||
SameSite: m.sameSite,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -131,8 +131,8 @@ func (m *Manager) ClearSession(w http.ResponseWriter) {
|
||||
Path: "/",
|
||||
MaxAge: -1, // Delete immediately
|
||||
HttpOnly: true,
|
||||
Secure: m.secure,
|
||||
SameSite: m.sameSite,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
82
internal/session/session_cookie_attributes_test.go
Normal file
82
internal/session/session_cookie_attributes_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSessionCookieAttributesAlwaysSecure verifies that every cookie
|
||||
// emitted by the session manager carries HttpOnly, Secure, and a
|
||||
// SameSite mode of Lax or stricter. Session cookies contain the
|
||||
// authentication state and must never be exposed to script (HttpOnly),
|
||||
// sent over plaintext HTTP (Secure), or attached to cross-site
|
||||
// requests (SameSite). Nothing may weaken these attributes.
|
||||
//
|
||||
// This covers both cookie-writing paths: CreateSession (the login
|
||||
// set-cookie path) and ClearSession (the logout delete-cookie path).
|
||||
func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
|
||||
mgr, err := NewManager("test-signing-key-12345")
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager() error = %v", err)
|
||||
}
|
||||
|
||||
writePaths := []struct {
|
||||
name string
|
||||
setCookie func(t *testing.T, w http.ResponseWriter)
|
||||
}{
|
||||
{
|
||||
name: "CreateSession",
|
||||
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||
t.Helper()
|
||||
if err := mgr.CreateSession(w); err != nil {
|
||||
t.Fatalf("CreateSession() error = %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClearSession",
|
||||
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||
t.Helper()
|
||||
mgr.ClearSession(w)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, writePath := range writePaths {
|
||||
t.Run(writePath.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writePath.setCookie(t, w)
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == CookieName {
|
||||
sessionCookie = c
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sessionCookie == nil {
|
||||
t.Fatalf("no cookie named %q was set", CookieName)
|
||||
}
|
||||
|
||||
t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v",
|
||||
sessionCookie.HttpOnly, sessionCookie.Secure, sessionCookie.SameSite)
|
||||
|
||||
if !sessionCookie.HttpOnly {
|
||||
t.Error("session cookie must have HttpOnly set")
|
||||
}
|
||||
|
||||
if !sessionCookie.Secure {
|
||||
t.Error("session cookie must have Secure set")
|
||||
}
|
||||
|
||||
if sessionCookie.SameSite != http.SameSiteLaxMode &&
|
||||
sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("session cookie SameSite = %v, want Lax (%v) or Strict (%v)",
|
||||
sessionCookie.SameSite, http.SameSiteLaxMode, http.SameSiteStrictMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestManager_CreateAndValidate(t *testing.T) {
|
||||
mgr, err := NewManager("test-signing-key-12345", false)
|
||||
mgr, err := NewManager("test-signing-key-12345")
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager() error = %v", err)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestManager_CreateAndValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
||||
mgr, _ := NewManager("test-signing-key-12345")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
||||
mgr, _ := NewManager("test-signing-key-12345")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{
|
||||
@@ -91,8 +91,8 @@ func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
||||
mgr1, _ := NewManager("signing-key-1", false)
|
||||
mgr2, _ := NewManager("signing-key-2", false)
|
||||
mgr1, _ := NewManager("signing-key-1")
|
||||
mgr2, _ := NewManager("signing-key-2")
|
||||
|
||||
// Create session with mgr1
|
||||
w := httptest.NewRecorder()
|
||||
@@ -118,7 +118,7 @@ func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManager_ClearSession(t *testing.T) {
|
||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
||||
mgr, _ := NewManager("test-signing-key-12345")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mgr.ClearSession(w)
|
||||
@@ -144,7 +144,7 @@ func TestManager_ClearSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManager_IsAuthenticated(t *testing.T) {
|
||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
||||
mgr, _ := NewManager("test-signing-key-12345")
|
||||
|
||||
// No session - should return false
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
@@ -175,8 +175,7 @@ func TestManager_IsAuthenticated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManager_CookieAttributes(t *testing.T) {
|
||||
// Test with secure=true
|
||||
mgr, _ := NewManager("test-key", true)
|
||||
mgr, _ := NewManager("test-key")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
_ = mgr.CreateSession(w)
|
||||
|
||||
Reference in New Issue
Block a user