Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package config
import (
"log"
"path/filepath"
"strings"
"time"
)

Expand All @@ -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
Expand All @@ -44,6 +48,9 @@ type Environment struct {

// Impresora
DefaultPrinter string

// Security
AllowedOrigins []string
}

// LogPath returns the full log file path for this environment.
Expand All @@ -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",
Expand All @@ -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, ",")

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing AllowedOrigins from the ldflag with strings.Split leaves leading/trailing whitespace intact and can introduce empty entries (e.g., trailing comma). This can make valid origins fail to match unexpectedly and makes configuration fragile. Consider splitting, trimming spaces on each entry, and filtering out empty strings before assigning cfg.AllowedOrigins (optionally validate patterns and log/ignore invalid ones).

Suggested change
cfg.AllowedOrigins = strings.Split(AllowedOrigins, ",")
rawOrigins := strings.Split(AllowedOrigins, ",")
cleanOrigins := make([]string, 0, len(rawOrigins))
for _, origin := range rawOrigins {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
cleanOrigins = append(cleanOrigins, trimmed)
}
if len(cleanOrigins) > 0 {
cfg.AllowedOrigins = cleanOrigins
}

Copilot uses AI. Check for mistakes.
}
log.Printf("[!] Unknown environment '%s', defaulting to 'local'", env)
return environments["local"]

return cfg
}
5 changes: 4 additions & 1 deletion internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 12 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,6 +70,7 @@ type Server struct {
shutdownChan chan struct{}
printerDiscovery PrinterLister
jobLimiter *JobRateLimiter
allowedOrigins []string
}

// NewServer creates a new WebSocket server
Expand All @@ -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,
}
}

Expand All @@ -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
Expand Down Expand Up @@ -132,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())
}

Expand Down
120 changes: 120 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package server

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/coder/websocket"
"github.com/adcondev/poster/pkg/connection"
"github.com/adcondev/ticket-daemon/internal/posprinter"
)

type mockPrinterDiscovery struct{}

func (m *mockPrinterDiscovery) GetPrinters(_ 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, 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, "")

// Case B: Connection from Disallowed Origin
optsBad := &websocket.DialOptions{
HTTPHeader: http.Header{
"Origin": []string{"http://evil.com"},
},
}

_, 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)")
}
Comment on lines +42 to +74

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative-origin assertions only check that websocket.Dial returns an error; a timeout/cancellation or other handshake failure would also satisfy this, which can mask regressions. To make the security behavior explicit and reduce flakiness, use a fresh context per dial and assert the failure is specifically an origin rejection (e.g., inspect the HTTP response status / close status / error type returned by the dial).

Copilot uses AI. Check for mistakes.
})

// 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, 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, "")

// Case B: Connection from Different Origin
optsBad := &websocket.DialOptions{
HTTPHeader: http.Header{
"Origin": []string{"http://external-site.com"},
},
}
_, 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)")
}
})
}
Loading