diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index fee65835..9d0a67d1 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -1,7 +1,9 @@ package handlers import ( + "net" "net/http" + "net/url" "os" "strconv" "strings" @@ -47,6 +49,82 @@ func requestScheme(c *gin.Context) string { return "http" } +func normalizeHost(rawHost string) string { + host := strings.TrimSpace(rawHost) + if host == "" { + return "" + } + + if strings.Contains(host, ":") { + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + host = parsedHost + } + } + + return strings.Trim(host, "[]") +} + +func originHost(rawURL string) string { + if rawURL == "" { + return "" + } + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "" + } + + return normalizeHost(parsedURL.Host) +} + +func isLocalHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return true + } + + return false +} + +func isLocalRequest(c *gin.Context) bool { + candidates := []string{} + + if c.Request != nil { + candidates = append(candidates, normalizeHost(c.Request.Host)) + + if c.Request.URL != nil { + candidates = append(candidates, normalizeHost(c.Request.URL.Host)) + } + + candidates = append(candidates, + originHost(c.Request.Header.Get("Origin")), + originHost(c.Request.Header.Get("Referer")), + ) + } + + if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { + parts := strings.Split(forwardedHost, ",") + for _, part := range parts { + candidates = append(candidates, normalizeHost(part)) + } + } + + for _, host := range candidates { + if host == "" { + continue + } + + if isLocalHost(host) { + return true + } + } + + return false +} + // setSecureCookie sets an auth cookie with security best practices // - HttpOnly: prevents JavaScript access (XSS protection) // - Secure: derived from request scheme to allow HTTP/IP logins when needed @@ -59,6 +137,11 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) { sameSite = http.SameSiteLaxMode } + if isLocalRequest(c) { + secure = false + sameSite = http.SameSiteLaxMode + } + // Use the host without port for domain domain := "" diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 26c0efcc..460b8922 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -96,6 +96,92 @@ func TestSetSecureCookie_HTTP_Lax(t *testing.T) { assert.Equal(t, http.SameSiteLaxMode, c.SameSite) } +func TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + _ = os.Setenv("CHARON_ENV", "production") + defer func() { _ = os.Unsetenv("CHARON_ENV") }() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://localhost:8080/login", http.NoBody) + req.Host = "localhost:8080" + req.Header.Set("X-Forwarded-Proto", "https") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_ForwardedHTTPS_LoopbackForcesInsecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + _ = os.Setenv("CHARON_ENV", "production") + defer func() { _ = os.Unsetenv("CHARON_ENV") }() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://127.0.0.1:8080/login", http.NoBody) + req.Host = "127.0.0.1:8080" + req.Header.Set("X-Forwarded-Proto", "https") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_ForwardedHostLocalhostForcesInsecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + _ = os.Setenv("CHARON_ENV", "production") + defer func() { _ = os.Unsetenv("CHARON_ENV") }() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://charon.local/login", http.NoBody) + req.Host = "charon.internal:8080" + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "localhost:8080") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestSetSecureCookie_OriginLoopbackForcesInsecure(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + _ = os.Setenv("CHARON_ENV", "production") + defer func() { _ = os.Unsetenv("CHARON_ENV") }() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://service.internal/login", http.NoBody) + req.Host = "service.internal:8080" + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("Origin", "http://127.0.0.1:8080") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + cookie := cookies[0] + assert.False(t, cookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + func TestAuthHandler_Login_Errors(t *testing.T) { t.Parallel() handler, _ := setupAuthHandler(t)