From bf531244fab7fc4e49057f41ffb28dbac054e9e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 02:36:23 +0000 Subject: [PATCH 1/4] fix(api): restore request body in SanitizeJSONMiddleware The SanitizeJSONMiddleware was consuming the request body without restoring it, causing subsequent handlers to receive empty bodies. This broke the setup admin endpoint which requires access to the request body for validation. Changes: - Modified SanitizeJSONMiddleware to read body with io.ReadAll - Restore body using io.NopCloser before calling Next() - Use json.Unmarshal instead of c.ShouldBindJSON to avoid consumption - Removed debug logging from setup handler (no longer needed) - Removed unused imports (bytes, io) from setup handler This fix ensures the request body is available to all handlers in the middleware chain, resolving the "EOF" error when submitting the setup form. --- api/internal/handlers/setup.go | 29 ---------------------- api/internal/middleware/inputvalidation.go | 16 +++++++++++- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/api/internal/handlers/setup.go b/api/internal/handlers/setup.go index 966309d6..a5736891 100644 --- a/api/internal/handlers/setup.go +++ b/api/internal/handlers/setup.go @@ -28,10 +28,8 @@ package handlers import ( - "bytes" "database/sql" "fmt" - "io" "net/http" "regexp" @@ -81,10 +79,6 @@ type SetupStatusResponse struct { func (h *SetupHandler) GetSetupStatus(c *gin.Context) { setupRequired, adminExists, hasPassword := h.isSetupRequired() - // Debug logging - fmt.Printf("DEBUG GetSetupStatus: setupRequired=%v, adminExists=%v, hasPassword=%v\n", - setupRequired, adminExists, hasPassword) - var message string if setupRequired { message = "Setup wizard is available - admin account needs password configuration" @@ -101,9 +95,6 @@ func (h *SetupHandler) GetSetupStatus(c *gin.Context) { Message: message, } - // Debug logging - fmt.Printf("DEBUG GetSetupStatus response: %+v\n", response) - c.JSON(http.StatusOK, response) } @@ -116,22 +107,17 @@ func (h *SetupHandler) isSetupRequired() (bool, bool, bool) { if err != nil { if err == sql.ErrNoRows { // Admin user doesn't exist yet - fmt.Printf("DEBUG isSetupRequired: Admin user not found (sql.ErrNoRows)\n") return false, false, false } // Database error - don't allow setup - fmt.Printf("DEBUG isSetupRequired: Database error: %v\n", err) return false, true, false } // Admin exists, check if password is set hasPassword := passwordHash.Valid && passwordHash.String != "" - fmt.Printf("DEBUG isSetupRequired: Admin found - passwordHash.Valid=%v, passwordHash.String=%q, hasPassword=%v\n", - passwordHash.Valid, passwordHash.String, hasPassword) // Setup required if admin exists but has no password setupRequired := !hasPassword - fmt.Printf("DEBUG isSetupRequired: setupRequired=%v\n", setupRequired) return setupRequired, true, hasPassword } @@ -175,10 +161,6 @@ func (h *SetupHandler) SetupAdmin(c *gin.Context) { // Check if setup is allowed setupRequired, adminExists, hasPassword := h.isSetupRequired() - // Debug logging - fmt.Printf("DEBUG SetupAdmin: setupRequired=%v, adminExists=%v, hasPassword=%v\n", - setupRequired, adminExists, hasPassword) - if !setupRequired { if !adminExists { c.JSON(http.StatusForbidden, gin.H{ @@ -196,17 +178,9 @@ func (h *SetupHandler) SetupAdmin(c *gin.Context) { } } - // Debug: Log request body - bodyBytes, _ := c.GetRawData() - fmt.Printf("DEBUG SetupAdmin request body: %s\n", string(bodyBytes)) - fmt.Printf("DEBUG SetupAdmin Content-Type: %s\n", c.ContentType()) - // Restore body for binding - c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - // Parse and validate request var req SetupAdminRequest if err := c.ShouldBindJSON(&req); err != nil { - fmt.Printf("DEBUG SetupAdmin bind error: %v\n", err) c.JSON(http.StatusBadRequest, gin.H{ "error": "Invalid request format", "details": err.Error(), @@ -214,9 +188,6 @@ func (h *SetupHandler) SetupAdmin(c *gin.Context) { return } - fmt.Printf("DEBUG SetupAdmin parsed: password=%d chars, email=%s\n", - len(req.Password), req.Email) - // Validate password confirmation if req.Password != req.PasswordConfirm { c.JSON(http.StatusBadRequest, gin.H{ diff --git a/api/internal/middleware/inputvalidation.go b/api/internal/middleware/inputvalidation.go index c2da5027..7e022f76 100644 --- a/api/internal/middleware/inputvalidation.go +++ b/api/internal/middleware/inputvalidation.go @@ -50,7 +50,10 @@ package middleware import ( + "bytes" + "encoding/json" "fmt" + "io" "net/http" "regexp" "strings" @@ -114,8 +117,19 @@ func (v *InputValidator) SanitizeJSONMiddleware() gin.HandlerFunc { return } + // Read and preserve the request body + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + c.Next() + return + } + + // Restore the body for handlers to read + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Try to parse as JSON map var data map[string]interface{} - if err := c.ShouldBindJSON(&data); err != nil { + if err := json.Unmarshal(bodyBytes, &data); err != nil { // If it's not a map, let it pass to the handler which will validate properly c.Next() return From e1579330eb2ee89fe42aaadb5bbd2e062d500f3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 02:46:19 +0000 Subject: [PATCH 2/4] fix(api): remove duplicate /auth path in AuthHandler routes The AuthHandler.RegisterRoutes was creating an extra /auth group when it was already being called with /api/v1/auth from main.go, causing login and other auth endpoints to be registered at /api/v1/auth/auth/* instead of /api/v1/auth/*. This resulted in 404 errors when trying to access: - POST /api/v1/auth/login - POST /api/v1/auth/refresh - POST /api/v1/auth/logout - SAML endpoints Changes: - Removed the router.Group("/auth") wrapper in RegisterRoutes - Routes now register directly on the provided router parameter - Added comment clarifying that router is already /api/v1/auth This ensures auth endpoints are accessible at the correct paths. --- api/internal/auth/handlers.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/api/internal/auth/handlers.go b/api/internal/auth/handlers.go index 70bfcfa5..82024a54 100644 --- a/api/internal/auth/handlers.go +++ b/api/internal/auth/handlers.go @@ -132,15 +132,13 @@ func NewAuthHandler(userDB *db.UserDB, jwtManager *JWTManager, samlAuth *SAMLAut // RegisterRoutes registers authentication routes func (h *AuthHandler) RegisterRoutes(router *gin.RouterGroup) { - auth := router.Group("/auth") - { - auth.POST("/login", h.Login) - auth.POST("/refresh", h.RefreshToken) - auth.POST("/logout", h.Logout) - auth.GET("/saml/login", h.SAMLLogin) - auth.POST("/saml/acs", h.SAMLCallback) - auth.GET("/saml/metadata", h.SAMLMetadata) - } + // Note: router is already /api/v1/auth from main.go + router.POST("/login", h.Login) + router.POST("/refresh", h.RefreshToken) + router.POST("/logout", h.Logout) + router.GET("/saml/login", h.SAMLLogin) + router.POST("/saml/acs", h.SAMLCallback) + router.GET("/saml/metadata", h.SAMLMetadata) } // LoginRequest represents a login request From 9e028424b33ac5f9f999695b566bd03a286818e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 02:55:25 +0000 Subject: [PATCH 3/4] fix(api): add WebSocket headers to CORS allowed headers The CORS middleware was not allowing WebSocket-specific headers, causing WebSocket upgrade requests to fail with CORS errors. Changes: - Added Upgrade, Connection headers for WebSocket protocol switch - Added Sec-WebSocket-Key for handshake validation - Added Sec-WebSocket-Version for protocol version negotiation - Added Sec-WebSocket-Extensions for extension negotiation - Added Sec-WebSocket-Protocol for subprotocol selection This fix allows WebSocket connections to properly upgrade from HTTP, resolving the "WebSocket Connection Error" on the frontend. --- api/cmd/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/cmd/main.go b/api/cmd/main.go index 685dfeee..e0a1cf36 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -843,7 +843,7 @@ func corsMiddleware() gin.HandlerFunc { c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") } - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Extensions, Sec-WebSocket-Protocol") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE") if c.Request.Method == "OPTIONS" { From 1c9be1f527e50a42e906daf9513ecd4c31ffd420 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 03:04:52 +0000 Subject: [PATCH 4/4] fix(api): fix WebSocket connections - origin check and auth middleware Fixed two critical issues preventing WebSocket connections: 1. WebSocket Origin Check (websocket.go): - Changed from ALLOWED_ORIGINS to CORS_ALLOWED_ORIGINS env var - Now uses same environment variable and logic as CORS middleware - Defaults to localhost:3000 and localhost:8000 when not configured - Ensures consistent origin validation across HTTP and WebSocket 2. Authentication Middleware (middleware.go): - Added special handling for WebSocket upgrade requests - Detects WebSocket upgrade via Upgrade and Connection headers - Returns HTTP status codes without JSON body for WebSocket requests - Prevents breaking WebSocket handshake with JSON responses - Auth failures now properly handled by WebSocket upgrader Why These Fixes Were Needed: - WebSocket origin check was using different env var, causing rejection - Auth middleware was writing JSON responses during WebSocket upgrade - JSON responses break the WebSocket handshake protocol - Connection would fail before upgrade could complete This resolves the "WebSocket Connection Error" preventing real-time updates in the frontend. --- api/internal/auth/middleware.go | 24 ++++++++++++++++++++++ api/internal/handlers/websocket.go | 32 ++++++++++++++++++------------ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go index f31ce7f9..63ed3cf7 100644 --- a/api/internal/auth/middleware.go +++ b/api/internal/auth/middleware.go @@ -143,9 +143,17 @@ import ( // Middleware creates an authentication middleware func Middleware(jwtManager *JWTManager, userDB *db.UserDB) gin.HandlerFunc { return func(c *gin.Context) { + // Check if this is a WebSocket upgrade request + isWebSocket := c.GetHeader("Upgrade") == "websocket" && c.GetHeader("Connection") == "Upgrade" + // Extract token from Authorization header authHeader := c.GetHeader("Authorization") if authHeader == "" { + // For WebSocket, abort without writing response (let upgrader handle it) + if isWebSocket { + c.AbortWithStatus(http.StatusUnauthorized) + return + } c.JSON(http.StatusUnauthorized, gin.H{ "error": "Authorization header required", }) @@ -156,6 +164,10 @@ func Middleware(jwtManager *JWTManager, userDB *db.UserDB) gin.HandlerFunc { // Check Bearer prefix parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { + if isWebSocket { + c.AbortWithStatus(http.StatusUnauthorized) + return + } c.JSON(http.StatusUnauthorized, gin.H{ "error": "Invalid authorization header format. Use: Bearer ", }) @@ -168,6 +180,10 @@ func Middleware(jwtManager *JWTManager, userDB *db.UserDB) gin.HandlerFunc { // Validate token claims, err := jwtManager.ValidateToken(tokenString) if err != nil { + if isWebSocket { + c.AbortWithStatus(http.StatusUnauthorized) + return + } c.JSON(http.StatusUnauthorized, gin.H{ "error": "Invalid or expired token", "message": err.Error(), @@ -179,6 +195,10 @@ func Middleware(jwtManager *JWTManager, userDB *db.UserDB) gin.HandlerFunc { // Verify user still exists and is active user, err := userDB.GetUser(c.Request.Context(), claims.UserID) if err != nil { + if isWebSocket { + c.AbortWithStatus(http.StatusUnauthorized) + return + } c.JSON(http.StatusUnauthorized, gin.H{ "error": "User not found", }) @@ -187,6 +207,10 @@ func Middleware(jwtManager *JWTManager, userDB *db.UserDB) gin.HandlerFunc { } if !user.Active { + if isWebSocket { + c.AbortWithStatus(http.StatusForbidden) + return + } c.JSON(http.StatusForbidden, gin.H{ "error": "User account is disabled", }) diff --git a/api/internal/handlers/websocket.go b/api/internal/handlers/websocket.go index 43992fd6..5f91502b 100644 --- a/api/internal/handlers/websocket.go +++ b/api/internal/handlers/websocket.go @@ -324,25 +324,31 @@ func checkWebSocketOrigin(r *http.Request) bool { return true } - // Get allowed origins from environment variable - // Format: ALLOWED_ORIGINS=https://app.streamspace.io,https://streamspace.io - allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS") + // Get allowed origins from environment variable (same as CORS middleware) + // Format: CORS_ALLOWED_ORIGINS=https://app.streamspace.io,https://streamspace.io + allowedOriginsEnv := os.Getenv("CORS_ALLOWED_ORIGINS") + + var allowedOrigins []string if allowedOriginsEnv != "" { - allowedOrigins := strings.Split(allowedOriginsEnv, ",") - for _, allowed := range allowedOrigins { - if strings.TrimSpace(allowed) == origin { - return true - } + // Parse comma-separated list of origins + for _, allowedOrigin := range strings.Split(allowedOriginsEnv, ",") { + allowedOrigins = append(allowedOrigins, strings.TrimSpace(allowedOrigin)) } } - // Check if origin matches the request host (same-origin) - requestHost := r.Host - if strings.HasPrefix(origin, "http://"+requestHost) || strings.HasPrefix(origin, "https://"+requestHost) { - return true + // If no origins specified, use localhost only for development (same as CORS middleware) + if len(allowedOrigins) == 0 { + allowedOrigins = []string{"http://localhost:3000", "http://localhost:8000"} + } + + // Check if origin is in allowed list + for _, allowed := range allowedOrigins { + if origin == allowed { + return true + } } - // Allow localhost and 127.0.0.1 for development + // Also allow any localhost or 127.0.0.1 origin for development if strings.Contains(origin, "localhost") || strings.Contains(origin, "127.0.0.1") { return true }