From fcda9819a703bc836d74fae86e9d9df768c3b710 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 19 Jan 2026 11:24:38 -0800 Subject: [PATCH 1/3] fix: secure session cookies and validate WebSocket origin Security fixes: - Add Secure, HttpOnly, SameSite flags to session cookies - HttpOnly: prevents JavaScript access (XSS protection) - Secure: cookies sent only over HTTPS in production - SameSite=Lax: CSRF protection while allowing OAuth redirects - Validate WebSocket origin against allowed origins - Check Origin header against ALLOWED_ORIGIN env var - Allow localhost in development mode - Fallback to same-origin check if no env var configured - Log rejected connection attempts - Update example.backend.env with security settings - Add ENV=production for secure mode - Add ALLOWED_ORIGIN for WebSocket validation - Better documentation of all variables Co-Authored-By: Claude Opus 4.5 --- backend/controllers/websocket.go | 41 +++++++++++++++++++++++++++++--- backend/main.go | 10 ++++++++ production/example.backend.env | 25 ++++++++++++++----- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/backend/controllers/websocket.go b/backend/controllers/websocket.go index 6f318b50..7ecb7e6c 100644 --- a/backend/controllers/websocket.go +++ b/backend/controllers/websocket.go @@ -2,6 +2,8 @@ package controllers import ( "net/http" + "os" + "strings" "ccsync_backend/utils" @@ -13,10 +15,43 @@ type JobStatus struct { Status string `json:"status"` } -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { +// checkWebSocketOrigin validates the Origin header against allowed origins +func checkWebSocketOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // No origin header - could be same-origin or non-browser client + return true + } + + // Get allowed origin from environment (e.g., "https://taskwarrior-server.ccextractor.org") + allowedOrigin := os.Getenv("ALLOWED_ORIGIN") + + // In development, allow localhost origins + if os.Getenv("ENV") != "production" { + if strings.HasPrefix(origin, "http://localhost") || + strings.HasPrefix(origin, "http://127.0.0.1") { + return true + } + } + + // Check against configured allowed origin + if allowedOrigin != "" && origin == allowedOrigin { return true - }, + } + + // If no ALLOWED_ORIGIN configured, check if origin matches the request host + // This provides same-origin protection as fallback + host := r.Host + if strings.Contains(origin, host) { + return true + } + + utils.Logger.Warnf("WebSocket connection rejected from origin: %s", origin) + return false +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: checkWebSocketOrigin, } var clients = make(map[*websocket.Conn]bool) diff --git a/backend/main.go b/backend/main.go index b98b2436..3cce57ed 100644 --- a/backend/main.go +++ b/backend/main.go @@ -80,6 +80,16 @@ func main() { utils.Logger.Fatal("SESSION_KEY environment variable is not set or empty") } store := sessions.NewCookieStore(sessionKey) + + // Configure secure cookie options + store.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, // 7 days + HttpOnly: true, // Prevent JavaScript access + Secure: os.Getenv("ENV") == "production", // HTTPS only in production + SameSite: http.SameSiteLaxMode, // CSRF protection (Lax allows OAuth redirects) + } + gob.Register(map[string]interface{}{}) app := controllers.App{Config: conf, SessionStore: store} diff --git a/production/example.backend.env b/production/example.backend.env index e01b533f..78f1e186 100644 --- a/production/example.backend.env +++ b/production/example.backend.env @@ -1,7 +1,20 @@ -REDIRECT_URL_DEV="http://localhost:8000/auth/callback" -SESSION_KEY="Random key" -CLIENT_SEC="Via Google Oauth" -CLIENT_ID="Via Google Oauth" -FRONTEND_ORIGIN_DEV="http://localhost" -CONTAINER_ORIGIN="http://production-syncserver-1:8080/" +# Environment: set to "production" for secure cookies and strict origin checking +ENV="production" + +# OAuth configuration +REDIRECT_URL_DEV="https://your-domain.com/auth/callback" +CLIENT_ID="your-google-oauth-client-id" +CLIENT_SEC="your-google-oauth-client-secret" + +# Session configuration (generate a random 32+ character key) +SESSION_KEY="generate-a-random-secret-key-here" + +# CORS and WebSocket origin (your frontend URL, no trailing slash) +FRONTEND_ORIGIN_DEV="https://your-domain.com" +ALLOWED_ORIGIN="https://your-domain.com" + +# Sync server container URL (internal Docker network) +CONTAINER_ORIGIN="http://syncserver:8080/" + +# Port (usually 8000) PORT=8000 From 4d80e59ebb7723cd75ddb261cc70e63eb692e2c3 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 19 Jan 2026 11:54:46 -0800 Subject: [PATCH 2/3] style: fix gofmt alignment in session options Co-Authored-By: Claude Opus 4.5 --- backend/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/main.go b/backend/main.go index 3cce57ed..00057bd1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -84,8 +84,8 @@ func main() { // Configure secure cookie options store.Options = &sessions.Options{ Path: "/", - MaxAge: 86400 * 7, // 7 days - HttpOnly: true, // Prevent JavaScript access + MaxAge: 86400 * 7, // 7 days + HttpOnly: true, // Prevent JavaScript access Secure: os.Getenv("ENV") == "production", // HTTPS only in production SameSite: http.SameSiteLaxMode, // CSRF protection (Lax allows OAuth redirects) } From f284ff9acd7128d93cb948eb91f0d07a85defc03 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 19 Jan 2026 12:21:36 -0800 Subject: [PATCH 3/3] refactor: address review feedback on WebSocket origin validation - Club empty origin check with development mode check as suggested - Replace strings.Contains with proper URL parsing for exact host match - In production, require Origin header (reject if missing) - Use url.Parse() for safe hostname extraction and comparison Co-Authored-By: Claude Opus 4.5 --- backend/controllers/websocket.go | 44 ++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/backend/controllers/websocket.go b/backend/controllers/websocket.go index 7ecb7e6c..5d245f5d 100644 --- a/backend/controllers/websocket.go +++ b/backend/controllers/websocket.go @@ -2,6 +2,7 @@ package controllers import ( "net/http" + "net/url" "os" "strings" @@ -18,31 +19,46 @@ type JobStatus struct { // checkWebSocketOrigin validates the Origin header against allowed origins func checkWebSocketOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") - if origin == "" { - // No origin header - could be same-origin or non-browser client - return true - } - - // Get allowed origin from environment (e.g., "https://taskwarrior-server.ccextractor.org") - allowedOrigin := os.Getenv("ALLOWED_ORIGIN") - // In development, allow localhost origins + // In development mode, be more permissive if os.Getenv("ENV") != "production" { - if strings.HasPrefix(origin, "http://localhost") || + if origin == "" || + strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "http://127.0.0.1") { return true } } - // Check against configured allowed origin + // In production, require an origin header + if origin == "" { + utils.Logger.Warn("WebSocket connection rejected: missing Origin header in production") + return false + } + + // Check against configured allowed origin (exact match) + allowedOrigin := os.Getenv("ALLOWED_ORIGIN") if allowedOrigin != "" && origin == allowedOrigin { return true } - // If no ALLOWED_ORIGIN configured, check if origin matches the request host - // This provides same-origin protection as fallback - host := r.Host - if strings.Contains(origin, host) { + // Fallback: parse origin and compare hostname exactly with request host + parsedOrigin, err := url.Parse(origin) + if err != nil { + utils.Logger.Warnf("WebSocket connection rejected: invalid origin URL: %s", origin) + return false + } + + // Extract hostname from request Host header (may include port) + requestHost := r.Host + if idx := strings.LastIndex(requestHost, ":"); idx != -1 { + // Be careful with IPv6 addresses like [::1]:8080 + if !strings.HasPrefix(requestHost, "[") || idx > strings.Index(requestHost, "]") { + requestHost = requestHost[:idx] + } + } + + // Exact hostname comparison + if parsedOrigin.Hostname() == requestHost { return true }