Skip to content

Commit 8b9ca56

Browse files
cfsmp3claude
andauthored
fix: secure session cookies and validate WebSocket origin (#405)
* 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 <noreply@anthropic.com> * style: fix gofmt alignment in session options Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f6a92b5 commit 8b9ca56

3 files changed

Lines changed: 83 additions & 9 deletions

File tree

backend/controllers/websocket.go

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package controllers
22

33
import (
44
"net/http"
5+
"net/url"
6+
"os"
7+
"strings"
58

69
"ccsync_backend/utils"
710

@@ -13,10 +16,58 @@ type JobStatus struct {
1316
Status string `json:"status"`
1417
}
1518

16-
var upgrader = websocket.Upgrader{
17-
CheckOrigin: func(r *http.Request) bool {
19+
// checkWebSocketOrigin validates the Origin header against allowed origins
20+
func checkWebSocketOrigin(r *http.Request) bool {
21+
origin := r.Header.Get("Origin")
22+
23+
// In development mode, be more permissive
24+
if os.Getenv("ENV") != "production" {
25+
if origin == "" ||
26+
strings.HasPrefix(origin, "http://localhost") ||
27+
strings.HasPrefix(origin, "http://127.0.0.1") {
28+
return true
29+
}
30+
}
31+
32+
// In production, require an origin header
33+
if origin == "" {
34+
utils.Logger.Warn("WebSocket connection rejected: missing Origin header in production")
35+
return false
36+
}
37+
38+
// Check against configured allowed origin (exact match)
39+
allowedOrigin := os.Getenv("ALLOWED_ORIGIN")
40+
if allowedOrigin != "" && origin == allowedOrigin {
1841
return true
19-
},
42+
}
43+
44+
// Fallback: parse origin and compare hostname exactly with request host
45+
parsedOrigin, err := url.Parse(origin)
46+
if err != nil {
47+
utils.Logger.Warnf("WebSocket connection rejected: invalid origin URL: %s", origin)
48+
return false
49+
}
50+
51+
// Extract hostname from request Host header (may include port)
52+
requestHost := r.Host
53+
if idx := strings.LastIndex(requestHost, ":"); idx != -1 {
54+
// Be careful with IPv6 addresses like [::1]:8080
55+
if !strings.HasPrefix(requestHost, "[") || idx > strings.Index(requestHost, "]") {
56+
requestHost = requestHost[:idx]
57+
}
58+
}
59+
60+
// Exact hostname comparison
61+
if parsedOrigin.Hostname() == requestHost {
62+
return true
63+
}
64+
65+
utils.Logger.Warnf("WebSocket connection rejected from origin: %s", origin)
66+
return false
67+
}
68+
69+
var upgrader = websocket.Upgrader{
70+
CheckOrigin: checkWebSocketOrigin,
2071
}
2172

2273
var clients = make(map[*websocket.Conn]bool)

backend/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ func main() {
8080
utils.Logger.Fatal("SESSION_KEY environment variable is not set or empty")
8181
}
8282
store := sessions.NewCookieStore(sessionKey)
83+
84+
// Configure secure cookie options
85+
store.Options = &sessions.Options{
86+
Path: "/",
87+
MaxAge: 86400 * 7, // 7 days
88+
HttpOnly: true, // Prevent JavaScript access
89+
Secure: os.Getenv("ENV") == "production", // HTTPS only in production
90+
SameSite: http.SameSiteLaxMode, // CSRF protection (Lax allows OAuth redirects)
91+
}
92+
8393
gob.Register(map[string]interface{}{})
8494

8595
app := controllers.App{Config: conf, SessionStore: store}

production/example.backend.env

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
REDIRECT_URL_DEV="http://localhost:8000/auth/callback"
2-
SESSION_KEY="Random key"
3-
CLIENT_SEC="Via Google Oauth"
4-
CLIENT_ID="Via Google Oauth"
5-
FRONTEND_ORIGIN_DEV="http://localhost"
6-
CONTAINER_ORIGIN="http://production-syncserver-1:8080/"
1+
# Environment: set to "production" for secure cookies and strict origin checking
2+
ENV="production"
3+
4+
# OAuth configuration
5+
REDIRECT_URL_DEV="https://your-domain.com/auth/callback"
6+
CLIENT_ID="your-google-oauth-client-id"
7+
CLIENT_SEC="your-google-oauth-client-secret"
8+
9+
# Session configuration (generate a random 32+ character key)
10+
SESSION_KEY="generate-a-random-secret-key-here"
11+
12+
# CORS and WebSocket origin (your frontend URL, no trailing slash)
13+
FRONTEND_ORIGIN_DEV="https://your-domain.com"
14+
ALLOWED_ORIGIN="https://your-domain.com"
15+
16+
# Sync server container URL (internal Docker network)
17+
CONTAINER_ORIGIN="http://syncserver:8080/"
18+
19+
# Port (usually 8000)
720
PORT=8000

0 commit comments

Comments
 (0)