diff options
| -rw-r--r-- | AGENTS.md | 1 | ||||
| -rw-r--r-- | PLAN.md | 1 | ||||
| -rw-r--r-- | internal/api/handlers.go | 4 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 91 | ||||
| -rw-r--r-- | internal/config.go | 11 | ||||
| -rw-r--r-- | internal/config_test.go | 14 |
6 files changed, 119 insertions, 3 deletions
@@ -250,6 +250,7 @@ This triggers `FSScanner.Scan()`, which: | `GC_INTERVAL_MINUTES` | `30` | ≥ 1 | Garbage collector tick interval | | `SHARE_DEFAULT_EXPIRY_DAYS` | `7` | ≥ 1 | Default share link lifetime | | `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | Log verbosity | +| `SECURE_COOKIES` | `true` | `true` / `false` | Set `Secure` flag on session cookies; set to `false` for plain-HTTP local deployments | **Important:** The K8s `Deployment` overrides `DB_PATH` to `/data/media.db` and `MEDIA_ROOT` to `/media` so the PVC mounts are used. Do not rely on the local defaults in a container. @@ -403,6 +403,7 @@ CREATE INDEX idx_shares_expires ON shares(expires_at); | `GC_INTERVAL_MINUTES` | `30` | Garbage collector tick | | `SHARE_DEFAULT_EXPIRY_DAYS` | `7` | Default share link lifetime | | `LOG_LEVEL` | `info` | Log verbosity | +| `SECURE_COOKIES` | `true` | Set `Secure` flag on session cookies; disable for plain-HTTP local deployments | --- diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 46ab41e..8b207c6 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -222,7 +222,7 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, value string) { Value: value, Path: "/", HttpOnly: true, - Secure: true, + Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, Expires: time.Now().Add(time.Duration(s.cfg.SessionTimeoutHours) * time.Hour), }) @@ -234,7 +234,7 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) { Value: "", Path: "/", HttpOnly: true, - Secure: true, + Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(0, 0), diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 8c2161c..99c009b 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -458,6 +458,97 @@ func TestServer_Login(t *testing.T) { }) } +func TestServer_SessionCookieSecure(t *testing.T) { + hasher := &staticHasher{fixed: "hashed"} + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, + GetUserByUsernameFunc: func(ctx context.Context, username string) (*model.User, error) { + return &model.User{ID: 1, Username: "alice", PasswordHash: "hashed"}, nil + }, + }, + } + repo := repository.MockSessionRepo{ + CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, + } + sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) + + t.Run("Secure=true by default", func(t *testing.T) { + cfg := &internal.Config{SessionTimeoutHours: 24, SecureCookies: true} + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) + body := `{"username":"alice","password":"correct"}` + req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + for _, c := range rr.Result().Cookies() { + if c.Name == "session" && c.Secure != true { + t.Fatalf("expected Secure=true, got Secure=%v", c.Secure) + } + } + }) + + t.Run("Secure=false", func(t *testing.T) { + cfg := &internal.Config{SessionTimeoutHours: 24, SecureCookies: false} + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) + body := `{"username":"alice","password":"correct"}` + req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + for _, c := range rr.Result().Cookies() { + if c.Name == "session" && c.Secure != false { + t.Fatalf("expected Secure=false, got Secure=%v", c.Secure) + } + } + }) + + t.Run("clear cookie respects Secure config", func(t *testing.T) { + var deleted string + sessStore := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, + }, + SessionRepo: repository.MockSessionRepo{ + GetSessionByIDFunc: func(ctx context.Context, id string) (*model.Session, error) { + if id == "abc" { + return &model.Session{ID: "abc", UserID: 1, ExpiresAt: time.Now().Add(time.Hour)}, nil + } + return nil, nil + }, + DeleteSessionFunc: func(ctx context.Context, id string) error { + deleted = id + return nil + }, + }, + } + logoutSM := auth.NewSessionManager(&sessStore.SessionRepo, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{SessionTimeoutHours: 24, SecureCookies: false} + srv := newTestServer(t, sessStore, nil, logoutSM, cfg, nil, nil, nil, nil) + req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "abc"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected %d, got %d", http.StatusNoContent, rr.Code) + } + if deleted != "abc" { + t.Fatalf("expected session abc to be deleted, got %q", deleted) + } + for _, c := range rr.Result().Cookies() { + if c.Name == "session" && c.Secure != false { + t.Fatalf("expected Secure=false on cleared cookie, got Secure=%v", c.Secure) + } + } + }) +} + func TestServer_Logout(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} diff --git a/internal/config.go b/internal/config.go index 952bbfd..3385005 100644 --- a/internal/config.go +++ b/internal/config.go @@ -17,6 +17,7 @@ const ( DefaultGCIntervalMinutes = 30 DefaultShareDefaultExpiryDays = 7 DefaultLogLevel = "info" + DefaultSecureCookies = true ) // Config holds all application configuration loaded from environment variables. @@ -29,6 +30,7 @@ type Config struct { GCIntervalMinutes int ShareDefaultExpiryDays int LogLevel string + SecureCookies bool } // envInt reads an integer environment variable, validates it with the given check, @@ -68,6 +70,7 @@ func LoadConfig() (*Config, error) { GCIntervalMinutes: DefaultGCIntervalMinutes, ShareDefaultExpiryDays: DefaultShareDefaultExpiryDays, LogLevel: DefaultLogLevel, + SecureCookies: DefaultSecureCookies, } validLevels := map[string]struct{}{ @@ -133,5 +136,13 @@ func LoadConfig() (*Config, error) { cfg.LogLevel = level } + if v := os.Getenv("SECURE_COOKIES"); v != "" { + b, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("invalid SECURE_COOKIES: %w", err) + } + cfg.SecureCookies = b + } + return cfg, nil } diff --git a/internal/config_test.go b/internal/config_test.go index f92cd58..3e4fed2 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -37,6 +37,9 @@ func TestLoadConfig_Defaults(t *testing.T) { if cfg.LogLevel != DefaultLogLevel { t.Errorf("LogLevel: expected %q, got %q", DefaultLogLevel, cfg.LogLevel) } + if cfg.SecureCookies != DefaultSecureCookies { + t.Errorf("SecureCookies: expected %v, got %v", DefaultSecureCookies, cfg.SecureCookies) + } } func TestLoadConfig_EnvOverrides(t *testing.T) { @@ -50,6 +53,7 @@ func TestLoadConfig_EnvOverrides(t *testing.T) { {"GC_INTERVAL_MINUTES", "60"}, {"SHARE_DEFAULT_EXPIRY_DAYS", "14"}, {"LOG_LEVEL", "debug"}, + {"SECURE_COOKIES", "false"}, }) cfg, err := LoadConfig() @@ -80,6 +84,9 @@ func TestLoadConfig_EnvOverrides(t *testing.T) { if cfg.LogLevel != "debug" { t.Errorf("LogLevel: expected %q, got %q", "debug", cfg.LogLevel) } + if cfg.SecureCookies != false { + t.Errorf("SecureCookies: expected false, got %v", cfg.SecureCookies) + } } func TestLoadConfig_InvalidValues(t *testing.T) { @@ -128,6 +135,11 @@ func TestLoadConfig_InvalidValues(t *testing.T) { env: []envPair{{"LOG_LEVEL", "trace"}}, wantErr: "invalid LOG_LEVEL", }, + { + name: "invalid SECURE_COOKIES", + env: []envPair{{"SECURE_COOKIES", "maybe"}}, + wantErr: "invalid SECURE_COOKIES", + }, } for _, tc := range cases { @@ -156,7 +168,7 @@ func clearEnv() { "PORT", "MEDIA_ROOT", "DB_PATH", "MAX_UPLOAD_SIZE_MB", "SESSION_TIMEOUT_HOURS", "GC_INTERVAL_MINUTES", "SHARE_DEFAULT_EXPIRY_DAYS", - "LOG_LEVEL", + "LOG_LEVEL", "SECURE_COOKIES", } { os.Unsetenv(k) } |
