From e9b0937aeda5e48d6922bcfe2f6a74a6767080eb Mon Sep 17 00:00:00 2001 From: adcondev <38170282+adcondev@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:29:24 +0000 Subject: [PATCH 1/2] Restrict WebSocket origins to prevent CSWSH vulnerability Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- internal/config/config.go | 26 ++++++-- internal/daemon/daemon.go | 5 +- internal/server/server.go | 16 +++-- internal/server/server_test.go | 108 +++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 internal/server/server_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 4544c1e..c43e7f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "log" "path/filepath" + "strings" "time" ) @@ -22,6 +23,9 @@ var ( AuthToken = "" // ServerPort is the default port for the service, can be overridden by environment config. ServerPort = "8766" + // AllowedOrigins is a comma-separated list of allowed origins injected via ldflags. + // Example: "https://pos.example.com,http://localhost:*" + AllowedOrigins = "" ) // Environment holds environment-specific settings @@ -44,6 +48,9 @@ type Environment struct { // Impresora DefaultPrinter string + + // Security + AllowedOrigins []string } // LogPath returns the full log file path for this environment. @@ -64,6 +71,8 @@ var environments = map[string]Environment{ QueueCapacity: 50, Verbose: false, DefaultPrinter: "", + // By default, restrict to localhost and file (Electron) for security + AllowedOrigins: []string{"http://localhost:*", "https://localhost:*", "file://*"}, }, "local": { Name: "LOCAL", @@ -75,14 +84,23 @@ var environments = map[string]Environment{ QueueCapacity: 50, Verbose: true, DefaultPrinter: "58mm PT-210", + // Allow all in local dev mode for convenience, but can be overridden + AllowedOrigins: []string{"*"}, }, } // GetEnvironment returns config for the specified environment. func GetEnvironment(env string) Environment { - if cfg, ok := environments[env]; ok { - return cfg + cfg, ok := environments[env] + if !ok { + log.Printf("[!] Unknown environment '%s', defaulting to 'local'", env) + cfg = environments["local"] + } + + // Override allowed origins from ldflags if provided + if AllowedOrigins != "" { + cfg.AllowedOrigins = strings.Split(AllowedOrigins, ",") } - log.Printf("[!] Unknown environment '%s', defaulting to 'local'", env) - return environments["local"] + + return cfg } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 0a4ac8a..2d55f86 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -73,7 +73,10 @@ func (p *Program) Start() error { p.printerDiscovery.LogStartupDiagnostics() // Initialize WebSocket server - p.wsServer = server.NewServer(server.Config{QueueSize: cfg.QueueCapacity}, p.printerDiscovery) + p.wsServer = server.NewServer(server.Config{ + QueueSize: cfg.QueueCapacity, + AllowedOrigins: cfg.AllowedOrigins, + }, p.printerDiscovery) // Initialize print worker p.printWorker = worker.NewWorker( diff --git a/internal/server/server.go b/internal/server/server.go index 84ab1c9..0fa6137 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -30,7 +30,8 @@ type PrinterLister interface { // Config holds server configuration type Config struct { - QueueSize int + QueueSize int + AllowedOrigins []string } // PrintJob represents a queued print request @@ -69,6 +70,7 @@ type Server struct { shutdownChan chan struct{} printerDiscovery PrinterLister jobLimiter *JobRateLimiter + allowedOrigins []string } // NewServer creates a new WebSocket server @@ -84,6 +86,7 @@ func NewServer(cfg Config, discovery PrinterLister) *Server { shutdownChan: make(chan struct{}), printerDiscovery: discovery, jobLimiter: NewJobRateLimiter(maxJobsPerMinute), // 30 print jobs per minute per client + allowedOrigins: cfg.AllowedOrigins, } } @@ -99,10 +102,13 @@ func (s *Server) JobQueue() <-chan *PrintJob { // HandleWebSocket handles WebSocket connections func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - InsecureSkipVerify: true, - OriginPatterns: []string{"*"}, - }) + opts := &websocket.AcceptOptions{ + OriginPatterns: s.allowedOrigins, + // InsecureSkipVerify is intentionally omitted (defaults to false) + // to enforce Origin check against OriginPatterns or Host. + } + + conn, err := websocket.Accept(w, r, opts) if err != nil { log.Printf("[WS] ❌ Error accepting client: %v", err) return diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..7c12fd4 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,108 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/adcondev/poster/pkg/connection" + "github.com/adcondev/ticket-daemon/internal/posprinter" + "github.com/coder/websocket" +) + +type mockPrinterDiscovery struct{} + +func (m *mockPrinterDiscovery) GetPrinters(forceRefresh bool) ([]connection.PrinterDetail, error) { + return []connection.PrinterDetail{}, nil +} + +func (m *mockPrinterDiscovery) GetSummary() posprinter.Summary { + return posprinter.Summary{} +} + +func TestWebSocketOrigin(t *testing.T) { + // 1. Test Restricted Origin (Default behavior / Explicit Allow) + t.Run("Restricted Origin", func(t *testing.T) { + // Create server with specific allowed origin + cfg := Config{ + QueueSize: 10, + AllowedOrigins: []string{"http://good.com"}, + } + discovery := &mockPrinterDiscovery{} + srv := NewServer(cfg, discovery) + defer srv.Shutdown() + + ts := httptest.NewServer(http.HandlerFunc(srv.HandleWebSocket)) + defer ts.Close() + + u := "ws" + ts.URL[4:] + + // Case A: Connection from Allowed Origin + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + opts := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Origin": []string{"http://good.com"}, + }, + } + + conn, _, err := websocket.Dial(ctx, u, opts) + if err != nil { + t.Fatalf("Connection from good.com failed: %v", err) + } + conn.Close(websocket.StatusNormalClosure, "") + + // Case B: Connection from Disallowed Origin + optsBad := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Origin": []string{"http://evil.com"}, + }, + } + + _, _, err = websocket.Dial(ctx, u, optsBad) + if err == nil { + t.Fatalf("Connection from evil.com succeeded (should fail)") + } + }) + + // 2. Test Same Origin Enforcement (When AllowedOrigins is empty/nil) + t.Run("Same Origin Enforcement", func(t *testing.T) { + cfg := Config{ + QueueSize: 10, + AllowedOrigins: nil, // Enforce same origin + } + discovery := &mockPrinterDiscovery{} + srv := NewServer(cfg, discovery) + defer srv.Shutdown() + + ts := httptest.NewServer(http.HandlerFunc(srv.HandleWebSocket)) + defer ts.Close() + + u := "ws" + ts.URL[4:] + + // Case A: Connection from Same Origin (Default behavior of Dial) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // websocket.Dial sets Origin to the URL's host by default, mimicking a same-origin request + conn, _, err := websocket.Dial(ctx, u, nil) + if err != nil { + t.Fatalf("Connection from same origin failed: %v", err) + } + conn.Close(websocket.StatusNormalClosure, "") + + // Case B: Connection from Different Origin + optsBad := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Origin": []string{"http://external-site.com"}, + }, + } + _, _, err = websocket.Dial(ctx, u, optsBad) + if err == nil { + t.Fatalf("Connection from external-site.com succeeded (should fail)") + } + }) +} From e64573c316e95ef5e97d5856f899659f0083f426 Mon Sep 17 00:00:00 2001 From: adcondev <38170282+adcondev@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:36:35 +0000 Subject: [PATCH 2/2] Restrict WebSocket origins to prevent CSWSH vulnerability Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- internal/server/server.go | 1 + internal/server/server_test.go | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 0fa6137..8a2ae33 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -138,6 +138,7 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { if err != nil { return } + //nolint:gosec // Client count is an integer and safe to log log.Printf("[WS] ➖ Client disconnected (remaining: %d)", s.clients.Count()) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7c12fd4..fe9c3a1 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,14 +7,14 @@ import ( "testing" "time" + "github.com/coder/websocket" "github.com/adcondev/poster/pkg/connection" "github.com/adcondev/ticket-daemon/internal/posprinter" - "github.com/coder/websocket" ) type mockPrinterDiscovery struct{} -func (m *mockPrinterDiscovery) GetPrinters(forceRefresh bool) ([]connection.PrinterDetail, error) { +func (m *mockPrinterDiscovery) GetPrinters(_ bool) ([]connection.PrinterDetail, error) { return []connection.PrinterDetail{}, nil } @@ -49,11 +49,14 @@ func TestWebSocketOrigin(t *testing.T) { }, } - conn, _, err := websocket.Dial(ctx, u, opts) + conn, resp, err := websocket.Dial(ctx, u, opts) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } if err != nil { t.Fatalf("Connection from good.com failed: %v", err) } - conn.Close(websocket.StatusNormalClosure, "") + _ = conn.Close(websocket.StatusNormalClosure, "") // Case B: Connection from Disallowed Origin optsBad := &websocket.DialOptions{ @@ -62,7 +65,10 @@ func TestWebSocketOrigin(t *testing.T) { }, } - _, _, err = websocket.Dial(ctx, u, optsBad) + _, respBad, err := websocket.Dial(ctx, u, optsBad) + if respBad != nil && respBad.Body != nil { + _ = respBad.Body.Close() + } if err == nil { t.Fatalf("Connection from evil.com succeeded (should fail)") } @@ -88,11 +94,14 @@ func TestWebSocketOrigin(t *testing.T) { defer cancel() // websocket.Dial sets Origin to the URL's host by default, mimicking a same-origin request - conn, _, err := websocket.Dial(ctx, u, nil) + conn, resp, err := websocket.Dial(ctx, u, nil) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } if err != nil { t.Fatalf("Connection from same origin failed: %v", err) } - conn.Close(websocket.StatusNormalClosure, "") + _ = conn.Close(websocket.StatusNormalClosure, "") // Case B: Connection from Different Origin optsBad := &websocket.DialOptions{ @@ -100,7 +109,10 @@ func TestWebSocketOrigin(t *testing.T) { "Origin": []string{"http://external-site.com"}, }, } - _, _, err = websocket.Dial(ctx, u, optsBad) + _, respBad, err := websocket.Dial(ctx, u, optsBad) + if respBad != nil && respBad.Body != nil { + _ = respBad.Body.Close() + } if err == nil { t.Fatalf("Connection from external-site.com succeeded (should fail)") }