fix(auth): set SameSite=None for viewer session cookie over HTTPS and update docs
This commit is contained in:
1 parent
85454610f4
commit
4fe09a0a3d
5 files changed
+69
-16
No files matched your search
@@ -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).
|
- **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.
|
- **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.
|
- **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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -88,29 +88,48 @@ func CookieSecure(r *http.Request) bool {
|
|||||||
return r.TLS != nil
|
return r.TLS != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCookie writes the admin session cookie.
|
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
|
||||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
// 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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: sameSite,
|
||||||
Secure: CookieSecure(r),
|
Secure: secure,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCookie removes the admin session cookie.
|
// ClearCookie removes a session cookie with the given SameSite policy.
|
||||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: sameSite,
|
||||||
Secure: CookieSecure(r),
|
Secure: secure,
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func TestSetCookie(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
expiresAt := time.Now().Add(1 * time.Hour)
|
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()
|
cookies := w.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
@@ -165,7 +165,7 @@ func TestClearCookie(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ClearCookie(w, req, "test_cookie")
|
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
cookies := w.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
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) {
|
func TestGetTokenFromCookie(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ func HandleAdminLogin(deps *Dependencies) http.HandlerFunc {
|
|||||||
|
|
||||||
expiresAt := time.Now().Add(config.AdminSessionTTL())
|
expiresAt := time.Now().Add(config.AdminSessionTTL())
|
||||||
deps.SessionStore.Add(token, expiresAt)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
|
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 {
|
if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
|
||||||
deps.SessionStore.Remove(token)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
|
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())
|
expiresAt := time.Now().Add(config.UserSessionTTL())
|
||||||
deps.UserSessions.Add(token, username, expiresAt)
|
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.
|
// 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 {
|
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
|
||||||
deps.UserSessions.Remove(token)
|
deps.UserSessions.Remove(token)
|
||||||
}
|
}
|
||||||
auth.ClearCookie(w, r, userSessionCookieName)
|
auth.ClearCookie(w, r, userSessionCookieName, auth.SameSiteForUserCookie(r))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -730,7 +730,7 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng
|
|||||||
#### Viewer User Accounts
|
#### 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`.
|
- 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`.
|
- Viewer sign-in UI (username/password) lives in `frontend/index.html`.
|
||||||
|
|
||||||
#### Rate Limiting and Abuse Controls
|
#### 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
|
#### CORS and Cross-Origin Behavior
|
||||||
|
|
||||||
- HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility.
|
- 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
|
#### Moderation and Privacy
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user