fix: set Secure/HttpOnly/SameSite on session cookies (closes #47) #48
3
TODO.md
3
TODO.md
@@ -56,9 +56,6 @@ returns 410; logout redirects back to login
|
|||||||
- P0: implement cache size management and eviction so the disk cannot
|
- P0: implement cache size management and eviction so the disk cannot
|
||||||
fill up
|
fill up
|
||||||
- P0: validate configuration on startup, fail fast on bad config
|
- P0: validate configuration on startup, fail fast on bad config
|
||||||
- P1: remove the ignored former secure toggle parameter from
|
|
||||||
session.NewManager and update its call sites (requires touching
|
|
||||||
existing tests; needs approval per repo rules)
|
|
||||||
- P1: implement blocked networks configuration to extend SSRF
|
- P1: implement blocked networks configuration to extend SSRF
|
||||||
protection
|
protection
|
||||||
- P1: rate limit global concurrent upstream fetches to prevent
|
- P1: rate limit global concurrent upstream fetches to prevent
|
||||||
|
|||||||
@@ -95,9 +95,8 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.log.Info("image service initialized")
|
s.log.Info("image service initialized")
|
||||||
|
|
||||||
// Initialize session manager (signing key is validated at config load
|
// Initialize session manager (signing key is validated at config load
|
||||||
// time). The second argument is ignored: session cookies are always
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
||||||
// Secure/HttpOnly/SameSite=Strict.
|
sessMgr, err := session.NewManager(s.config.SigningKey)
|
||||||
sessMgr, err := session.NewManager(s.config.SigningKey, true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,8 @@ type Manager struct {
|
|||||||
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
|
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
|
||||||
// attributes; this cannot be configured. Browsers treat http://localhost as a
|
// attributes; this cannot be configured. Browsers treat http://localhost as a
|
||||||
// trustworthy origin and accept Secure cookies there, so local development
|
// trustworthy origin and accept Secure cookies there, so local development
|
||||||
// keeps working. The second parameter is the former secure toggle: it is
|
// keeps working.
|
||||||
// ignored and retained only so existing call sites keep compiling; it will be
|
func NewManager(signingKey string) (*Manager, error) {
|
||||||
// removed in a follow-up change.
|
|
||||||
func NewManager(signingKey string, _ bool) (*Manager, error) {
|
|
||||||
masterKey := []byte(signingKey)
|
masterKey := []byte(signingKey)
|
||||||
|
|
||||||
// Derive separate keys for HMAC (hash) and encryption (block)
|
// Derive separate keys for HMAC (hash) and encryption (block)
|
||||||
|
|||||||
@@ -8,85 +8,75 @@ import (
|
|||||||
|
|
||||||
// TestSessionCookieAttributesAlwaysSecure verifies that every cookie
|
// TestSessionCookieAttributesAlwaysSecure verifies that every cookie
|
||||||
// emitted by the session manager carries HttpOnly, Secure, and a
|
// emitted by the session manager carries HttpOnly, Secure, and a
|
||||||
// SameSite mode of Lax or stricter, regardless of how the manager was
|
// SameSite mode of Lax or stricter. Session cookies contain the
|
||||||
// constructed. Session cookies contain the authentication state and
|
// authentication state and must never be exposed to script (HttpOnly),
|
||||||
// must never be exposed to script (HttpOnly), sent over plaintext HTTP
|
// sent over plaintext HTTP (Secure), or attached to cross-site
|
||||||
// (Secure), or attached to cross-site requests (SameSite). No
|
// requests (SameSite). Nothing may weaken these attributes.
|
||||||
// constructor argument may weaken these attributes.
|
|
||||||
//
|
//
|
||||||
// This covers both cookie-writing paths: CreateSession (the login
|
// This covers both cookie-writing paths: CreateSession (the login
|
||||||
// set-cookie path) and ClearSession (the logout delete-cookie path).
|
// set-cookie path) and ClearSession (the logout delete-cookie path).
|
||||||
func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
|
func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
|
||||||
for _, constructorBoolArg := range []bool{false, true} {
|
mgr, err := NewManager("test-signing-key-12345")
|
||||||
mgr, err := NewManager("test-signing-key-12345", constructorBoolArg)
|
if err != nil {
|
||||||
if err != nil {
|
t.Fatalf("NewManager() error = %v", err)
|
||||||
t.Fatalf("NewManager() error = %v", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
writePaths := []struct {
|
writePaths := []struct {
|
||||||
name string
|
name string
|
||||||
setCookie func(t *testing.T, w http.ResponseWriter)
|
setCookie func(t *testing.T, w http.ResponseWriter)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "CreateSession",
|
name: "CreateSession",
|
||||||
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := mgr.CreateSession(w); err != nil {
|
if err := mgr.CreateSession(w); err != nil {
|
||||||
t.Fatalf("CreateSession() error = %v", err)
|
t.Fatalf("CreateSession() error = %v", err)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "ClearSession",
|
{
|
||||||
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
name: "ClearSession",
|
||||||
t.Helper()
|
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||||
mgr.ClearSession(w)
|
t.Helper()
|
||||||
},
|
mgr.ClearSession(w)
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
|
||||||
for _, writePath := range writePaths {
|
for _, writePath := range writePaths {
|
||||||
testName := writePath.name
|
t.Run(writePath.name, func(t *testing.T) {
|
||||||
if constructorBoolArg {
|
w := httptest.NewRecorder()
|
||||||
testName += "/constructorBoolArg=true"
|
writePath.setCookie(t, w)
|
||||||
} else {
|
|
||||||
testName += "/constructorBoolArg=false"
|
var sessionCookie *http.Cookie
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
if c.Name == CookieName {
|
||||||
|
sessionCookie = c
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run(testName, func(t *testing.T) {
|
if sessionCookie == nil {
|
||||||
w := httptest.NewRecorder()
|
t.Fatalf("no cookie named %q was set", CookieName)
|
||||||
writePath.setCookie(t, w)
|
}
|
||||||
|
|
||||||
var sessionCookie *http.Cookie
|
t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v",
|
||||||
for _, c := range w.Result().Cookies() {
|
sessionCookie.HttpOnly, sessionCookie.Secure, sessionCookie.SameSite)
|
||||||
if c.Name == CookieName {
|
|
||||||
sessionCookie = c
|
|
||||||
|
|
||||||
break
|
if !sessionCookie.HttpOnly {
|
||||||
}
|
t.Error("session cookie must have HttpOnly set")
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionCookie == nil {
|
if !sessionCookie.Secure {
|
||||||
t.Fatalf("no cookie named %q was set", CookieName)
|
t.Error("session cookie must have Secure set")
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v",
|
if sessionCookie.SameSite != http.SameSiteLaxMode &&
|
||||||
sessionCookie.HttpOnly, sessionCookie.Secure, sessionCookie.SameSite)
|
sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Errorf("session cookie SameSite = %v, want Lax (%v) or Strict (%v)",
|
||||||
if !sessionCookie.HttpOnly {
|
sessionCookie.SameSite, http.SameSiteLaxMode, http.SameSiteStrictMode)
|
||||||
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) {
|
func TestManager_CreateAndValidate(t *testing.T) {
|
||||||
mgr, err := NewManager("test-signing-key-12345", false)
|
mgr, err := NewManager("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewManager() error = %v", err)
|
t.Fatalf("NewManager() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestManager_CreateAndValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_NoCookie(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)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_TamperedCookie(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 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.AddCookie(&http.Cookie{
|
req.AddCookie(&http.Cookie{
|
||||||
@@ -91,8 +91,8 @@ func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
||||||
mgr1, _ := NewManager("signing-key-1", false)
|
mgr1, _ := NewManager("signing-key-1")
|
||||||
mgr2, _ := NewManager("signing-key-2", false)
|
mgr2, _ := NewManager("signing-key-2")
|
||||||
|
|
||||||
// Create session with mgr1
|
// Create session with mgr1
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -118,7 +118,7 @@ func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ClearSession(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()
|
w := httptest.NewRecorder()
|
||||||
mgr.ClearSession(w)
|
mgr.ClearSession(w)
|
||||||
@@ -144,7 +144,7 @@ func TestManager_ClearSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_IsAuthenticated(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
|
// No session - should return false
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
@@ -175,8 +175,7 @@ func TestManager_IsAuthenticated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_CookieAttributes(t *testing.T) {
|
func TestManager_CookieAttributes(t *testing.T) {
|
||||||
// Test with secure=true
|
mgr, _ := NewManager("test-key")
|
||||||
mgr, _ := NewManager("test-key", true)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
_ = mgr.CreateSession(w)
|
_ = mgr.CreateSession(w)
|
||||||
|
|||||||
Reference in New Issue
Block a user