summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md1
-rw-r--r--PLAN.md1
-rw-r--r--internal/api/handlers.go4
-rw-r--r--internal/api/handlers_test.go91
-rw-r--r--internal/config.go11
-rw-r--r--internal/config_test.go14
6 files changed, 119 insertions, 3 deletions
diff --git a/AGENTS.md b/AGENTS.md
index b558dd9..74fe1ae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/PLAN.md b/PLAN.md
index 7e9481b..86f89c9 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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)
}