diff --git a/CHANGELOG.md b/CHANGELOG.md index 01349ff..8630924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - **Chat font sizing**: `.message-text` set to `0.95rem` (slightly smaller than the default, larger than the `0.83rem` system/flash text). - **R2 retention by run count + size**: Playout now keeps the newest `R2_KEEP_RUNS` run folders (default 2) and enforces an `R2_MAX_RETENTION_BYTES` cap (default 10 GiB), replacing the old 24h time-based `R2_SEGMENT_RETENTION_HOURS` cleanup. Cleanup runs after each new run plus an hourly backstop, and never deletes the live manifest. Adds `linearvoid_playout_r2_runs_retained` and `linearvoid_playout_r2_bytes_retained` gauges. - **Cloudflare Workers frontend hosting**: Replaced Cloudflare Pages with a Cloudflare Worker (`linearvoid-frontend`) that serves the static SPA via Workers Static Assets. Added `frontend/wrangler.toml` and renamed the deploy workflow to `.gitea/workflows/workers-deploy.yml` (builds the SPA and runs `npx wrangler deploy`). Architecture, setup/deployment, and ops documentation updated from Pages to Workers. +- **Viewer session cookie cross-site**: The `linearvoid_user_session` cookie now uses `SameSite=None; Secure` over HTTPS (so it is sent cross-site from the Workers-served SPA) and `SameSite=Lax` over plain HTTP (local dev). The admin session cookie remains `SameSite=Strict`. ### Fixed diff --git a/backend/chat/src/auth/auth.go b/backend/chat/src/auth/auth.go index 6a8884a..728edd1 100644 --- a/backend/chat/src/auth/auth.go +++ b/backend/chat/src/auth/auth.go @@ -88,29 +88,48 @@ func CookieSecure(r *http.Request) bool { return r.TLS != nil } -// SetCookie writes the admin session cookie. -func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) { +// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie. +// Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the +// Workers-served SPA; over plain HTTP (local dev) it falls back to Lax. +func SameSiteForUserCookie(r *http.Request) http.SameSite { + if CookieSecure(r) { + return http.SameSiteNoneMode + } + return http.SameSiteLaxMode +} + +// SetCookie writes a session cookie with the given SameSite policy. +// SameSite=None is only valid together with Secure, which is enforced here. +func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) { + secure := CookieSecure(r) + if sameSite == http.SameSiteNoneMode { + secure = true + } http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: token, Path: "/", HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Secure: CookieSecure(r), + SameSite: sameSite, + Secure: secure, Expires: expiresAt, MaxAge: int(time.Until(expiresAt).Seconds()), }) } -// ClearCookie removes the admin session cookie. -func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) { +// ClearCookie removes a session cookie with the given SameSite policy. +func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) { + secure := CookieSecure(r) + if sameSite == http.SameSiteNoneMode { + secure = true + } http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: "", Path: "/", HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Secure: CookieSecure(r), + SameSite: sameSite, + Secure: secure, Expires: time.Unix(0, 0), MaxAge: -1, }) diff --git a/backend/chat/src/auth/auth_test.go b/backend/chat/src/auth/auth_test.go index ec697e5..b8312a7 100644 --- a/backend/chat/src/auth/auth_test.go +++ b/backend/chat/src/auth/auth_test.go @@ -134,7 +134,7 @@ func TestSetCookie(t *testing.T) { w := httptest.NewRecorder() expiresAt := time.Now().Add(1 * time.Hour) - SetCookie(w, req, "test_cookie", "abc123", expiresAt) + SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode) cookies := w.Result().Cookies() if len(cookies) != 1 { @@ -165,7 +165,7 @@ func TestClearCookie(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() - ClearCookie(w, req, "test_cookie") + ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode) cookies := w.Result().Cookies() if len(cookies) != 1 { @@ -183,6 +183,38 @@ func TestClearCookie(t *testing.T) { } } +func TestSameSiteForUserCookie(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Forwarded-Proto", "https") + if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode { + t.Errorf("SameSiteForUserCookie(https) = %v, want None", got) + } + + req2 := httptest.NewRequest("GET", "/", nil) + if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode { + t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got) + } +} + +func TestSetCookie_NoneForcesSecure(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + expiresAt := time.Now().Add(1 * time.Hour) + SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode) + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + c := cookies[0] + if c.SameSite != http.SameSiteNoneMode { + t.Errorf("cookie SameSite = %v, want None", c.SameSite) + } + if !c.Secure { + t.Error("cookie Secure = false, want true (SameSite=None requires Secure)") + } +} + func TestGetTokenFromCookie(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) req.AddCookie(&http.Cookie{Name: "session", Value: "token123"}) diff --git a/backend/chat/src/handlers/handlers.go b/backend/chat/src/handlers/handlers.go index 35a3251..f10b95d 100644 --- a/backend/chat/src/handlers/handlers.go +++ b/backend/chat/src/handlers/handlers.go @@ -291,7 +291,7 @@ func HandleAdminLogin(deps *Dependencies) http.HandlerFunc { expiresAt := time.Now().Add(config.AdminSessionTTL()) deps.SessionStore.Add(token, expiresAt) - auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt) + auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt, http.SameSiteStrictMode) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()}) @@ -309,7 +309,7 @@ func HandleAdminLogout(deps *Dependencies) http.HandlerFunc { if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok { deps.SessionStore.Remove(token) } - auth.ClearCookie(w, r, config.AdminSessionCookieName) + auth.ClearCookie(w, r, config.AdminSessionCookieName, http.SameSiteStrictMode) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false}) @@ -368,7 +368,7 @@ func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request } expiresAt := time.Now().Add(config.UserSessionTTL()) deps.UserSessions.Add(token, username, expiresAt) - auth.SetCookie(w, r, userSessionCookieName, token, expiresAt) + auth.SetCookie(w, r, userSessionCookieName, token, expiresAt, auth.SameSiteForUserCookie(r)) } // HandleUserRegister creates a new chat user and signs them in. @@ -474,7 +474,7 @@ func HandleUserLogout(deps *Dependencies) http.HandlerFunc { if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok { deps.UserSessions.Remove(token) } - auth.ClearCookie(w, r, userSessionCookieName) + auth.ClearCookie(w, r, userSessionCookieName, auth.SameSiteForUserCookie(r)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"ok": true}) } diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c450387..85ae029 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -730,7 +730,7 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng #### Viewer User Accounts - Register/login/logout via `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`; current session via `GET /api/auth/session`. -- Passwords are hashed with bcrypt; sessions use a `linearvoid_user_session` cookie with a 30-day default TTL (`USER_SESSION_TTL_HOURS`). +- Passwords are hashed with bcrypt; sessions use a `linearvoid_user_session` cookie with a 30-day default TTL (`USER_SESSION_TTL_HOURS`). The cookie is `SameSite=None; Secure` over HTTPS (cross-site from the Workers SPA) and `SameSite=Lax` over plain HTTP (local dev). - Viewer sign-in UI (username/password) lives in `frontend/index.html`. #### Rate Limiting and Abuse Controls @@ -742,7 +742,8 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng #### CORS and Cross-Origin Behavior - HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility. -- Admin/authenticated routes are expected to run same-origin behind nginx auth gates. +- API routes (`/api/*`) include `Access-Control-Allow-Origin` (reflecting an allowlisted origin) and `Access-Control-Allow-Credentials: true` so the Workers-served SPA can call the app origin cross-origin. +- The viewer session cookie (`linearvoid_user_session`) uses `SameSite=None; Secure` over HTTPS so it is sent cross-site; the admin session cookie remains `SameSite=Strict`. #### Moderation and Privacy