From 22e35dd52560acc4c17522b029c215c5cc6e14de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0ediv=C3=BD?= Date: Wed, 28 Oct 2026 17:11:31 +0100 Subject: [PATCH] server: reject cross-origin WebSocket handshakes when CORS is empty. With an empty server.cors, AllowOrigin accepted every Origin, so a hostile page could open an authenticated WebSocket using the SameSite=None session cookie. Now only same-origin, listed origins, or '*' are accepted. Discovered by Yves Soete of Blacksight LLC. --- server/internal/config/server.go | 7 +++---- server/internal/http/manager.go | 10 +++++++++- server/internal/http/manager_test.go | 21 +++++++++++++++++++++ webpage/docs/configuration/help.txt | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/server/internal/config/server.go b/server/internal/config/server.go index aa0667a9..5289b782 100644 --- a/server/internal/config/server.go +++ b/server/internal/config/server.go @@ -62,7 +62,7 @@ func (Server) Init(cmd *cobra.Command) error { return err } - cmd.PersistentFlags().StringSlice("server.cors", []string{}, "list of allowed origins for CORS, if empty CORS is disabled, if '*' is present all origins are allowed") + cmd.PersistentFlags().StringSlice("server.cors", []string{}, "list of allowed origins for CORS and WebSocket connections, if empty only same-origin WebSocket connections are accepted, if '*' is present all origins are allowed") if err := viper.BindPFlag("server.cors", cmd.PersistentFlags().Lookup("server.cors")); err != nil { return err } @@ -193,9 +193,8 @@ func (s *Server) HasCors() bool { } func (s *Server) AllowOrigin(origin string) bool { - // if CORS is not configured, or a wildcard is present, - // allow WebSocket connections from any origin - if len(s.CORS) == 0 || s.CORS[0] == "*" { + // only an explicit wildcard allows any origin + if len(s.CORS) > 0 && s.CORS[0] == "*" { return true } diff --git a/server/internal/http/manager.go b/server/internal/http/manager.go index 3be60f58..493c78a8 100644 --- a/server/internal/http/manager.go +++ b/server/internal/http/manager.go @@ -3,8 +3,10 @@ package http import ( "context" "net/http" + "net/url" "os" "path" + "strings" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog" @@ -53,7 +55,13 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c router.Get("/api/ws", WebSocketManager.Upgrade(func(r *http.Request) bool { origin := r.Header.Get("Origin") - return origin == "" || config.AllowOrigin(origin) + if origin == "" || config.AllowOrigin(origin) { + return true + } + + // by default, only same-origin browser connections are accepted + u, err := url.Parse(origin) + return err == nil && u.Host != "" && strings.EqualFold(u.Host, r.Host) })) batch := batchHandler{ diff --git a/server/internal/http/manager_test.go b/server/internal/http/manager_test.go index 60afbecc..f5d08153 100644 --- a/server/internal/http/manager_test.go +++ b/server/internal/http/manager_test.go @@ -109,3 +109,24 @@ func TestWebSocketOriginAllowsInternalDialWithoutOrigin(t *testing.T) { t.Fatal("unconfigured browser Origin was accepted") } } + +func TestWebSocketOriginDefaultsToSameOrigin(t *testing.T) { + webSocketManager := &originCapturingWebSocketManager{} + serverConfig := &config.Server{} + memberConfig := &config.Member{} + sessionManager := session.New(&config.Session{}) + apiManager := api.New(sessionManager, member.New(sessionManager, memberConfig), nil, nil, memberConfig, serverConfig) + New(webSocketManager, apiManager, serverConfig, memberConfig) + + request := httptest.NewRequest(http.MethodGet, "http://neko.example.test/api/ws", nil) + + request.Header.Set("Origin", "http://neko.example.test") + if !webSocketManager.checkOrigin(request) { + t.Fatal("same Origin was rejected") + } + + request.Header.Set("Origin", "https://attacker.example") + if webSocketManager.checkOrigin(request) { + t.Fatal("cross-site Origin was accepted with empty CORS") + } +} diff --git a/webpage/docs/configuration/help.txt b/webpage/docs/configuration/help.txt index bf621d79..6393cbcd 100644 --- a/webpage/docs/configuration/help.txt +++ b/webpage/docs/configuration/help.txt @@ -43,7 +43,7 @@ --plugins.required if true, neko will exit if there is an error when loading a plugin --server.bind string address/port/socket to serve neko (default "127.0.0.1:8080") --server.cert string path to the SSL cert used to secure the neko server - --server.cors strings list of allowed origins for CORS, if empty CORS is disabled, if '*' is present all origins are allowed + --server.cors strings list of allowed origins for CORS and WebSocket connections, if empty only same-origin WebSocket connections are accepted, if '*' is present all origins are allowed --server.key string path to the SSL key used to secure the neko server --server.metrics enable prometheus metrics available at /metrics (default true) --server.path_prefix string path prefix for HTTP requests (default "/")