diff --git a/internal/auth/auth.go b/internal/auth/auth.go index bf54922..2e96480 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -145,6 +145,7 @@ func (m *Manager) SetSessionCookie(w http.ResponseWriter) string { Value: token, Path: "/", MaxAge: int(SessionDuration.Seconds()), + Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) @@ -158,6 +159,7 @@ func (m *Manager) ClearSessionCookie(w http.ResponseWriter) { Value: "", Path: "/", MaxAge: -1, + Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 0a4ac8a..066da03 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -19,6 +19,7 @@ import ( embed "github.com/adcondev/ticket-daemon" "github.com/adcondev/ticket-daemon/internal/auth" "github.com/adcondev/ticket-daemon/internal/config" + "github.com/adcondev/ticket-daemon/internal/posprinter" "github.com/adcondev/ticket-daemon/internal/server" "github.com/adcondev/ticket-daemon/internal/worker" ) @@ -133,7 +134,7 @@ func (p *Program) Start() error { Uptime: int(time.Since(p.startTime).Seconds()), } - if response.Printers.Status == "error" { + if response.Printers.Status == posprinter.StatusError { response.Status = "degraded" } @@ -318,7 +319,7 @@ func serveDashboard(tmpl *template.Template) http.HandlerFunc { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - //nolint:gosec // False positive: passing token to internal template + data := struct{ AuthToken string }{AuthToken: config.AuthToken} if err := tmpl.Execute(w, data); err != nil { log.Printf("[X] Error rendering dashboard: %v", err) diff --git a/internal/daemon/printer_discovery.go b/internal/daemon/printer_discovery.go index 7a6c7b4..561160a 100644 --- a/internal/daemon/printer_discovery.go +++ b/internal/daemon/printer_discovery.go @@ -56,7 +56,7 @@ func (pd *PrinterDiscovery) GetPrinters(forceRefresh bool) ([]connection.Printer func (pd *PrinterDiscovery) GetSummary() posprinter.Summary { printers, err := pd.GetPrinters(false) if err != nil { - return posprinter.Summary{Status: "error", DetectedCount: 0} + return posprinter.Summary{Status: posprinter.StatusError, DetectedCount: 0} } thermal := connection.FilterThermalPrinters(printers) @@ -74,7 +74,7 @@ func (pd *PrinterDiscovery) GetSummary() posprinter.Summary { if len(thermal) == 0 && len(physical) > 0 { status = "warning" } else if len(physical) == 0 { - status = "error" + status = posprinter.StatusError } return posprinter.Summary{ diff --git a/internal/posprinter/types.go b/internal/posprinter/types.go index d34fc4d..0671245 100644 --- a/internal/posprinter/types.go +++ b/internal/posprinter/types.go @@ -1,6 +1,9 @@ // Package posprinter contains shared types to avoid import cycles. package posprinter +const StatusError = "error" + + // Summary PrinterSummary provides lightweight overview for health checks type Summary struct { Status string `json:"status"` // "ok", "warning", "error" diff --git a/internal/server/rate_limit.go b/internal/server/rate_limit.go index 7ba1000..26664bf 100644 --- a/internal/server/rate_limit.go +++ b/internal/server/rate_limit.go @@ -11,6 +11,7 @@ type JobRateLimiter struct { mu sync.Mutex attempts map[string][]time.Time maxPerMin int + now func() time.Time } // NewJobRateLimiter creates a limiter allowing maxPerMinute jobs per client. @@ -18,6 +19,7 @@ func NewJobRateLimiter(maxPerMinute int) *JobRateLimiter { return &JobRateLimiter{ attempts: make(map[string][]time.Time), maxPerMin: maxPerMinute, + now: time.Now, } } @@ -26,7 +28,12 @@ func (rl *JobRateLimiter) Allow(clientAddr string) bool { rl.mu.Lock() defer rl.mu.Unlock() - now := time.Now() + var now time.Time + if rl.now != nil { + now = rl.now() + } else { + now = time.Now() + } cutoff := now.Add(-time.Minute) recent := make([]time.Time, 0, rl.maxPerMin) diff --git a/internal/server/rate_limit_test.go b/internal/server/rate_limit_test.go new file mode 100644 index 0000000..07986ea --- /dev/null +++ b/internal/server/rate_limit_test.go @@ -0,0 +1,70 @@ +package server + +import ( + "testing" + "time" +) + +func TestJobRateLimiter(t *testing.T) { + // We set max to 3 requests per minute + maxPerMin := 3 + rl := NewJobRateLimiter(maxPerMin) + + // Mock time + currentTime := time.Now() + rl.now = func() time.Time { + return currentTime + } + + clientAddr := "192.168.1.1:1234" + + // 1st request - should be allowed + if !rl.Allow(clientAddr) { + t.Errorf("Expected 1st request to be allowed") + } + + // 2nd request - should be allowed + if !rl.Allow(clientAddr) { + t.Errorf("Expected 2nd request to be allowed") + } + + // 3rd request - should be allowed + if !rl.Allow(clientAddr) { + t.Errorf("Expected 3rd request to be allowed") + } + + // 4th request - should be denied (limit is 3) + if rl.Allow(clientAddr) { + t.Errorf("Expected 4th request to be denied") + } + + // Fast forward time by 30 seconds + currentTime = currentTime.Add(30 * time.Second) + + // Still denied + if rl.Allow(clientAddr) { + t.Errorf("Expected request after 30s to be denied") + } + + // Fast forward time to clear the first request (1 minute and 1 second total) + currentTime = currentTime.Add(31 * time.Second) + + // Now it should be allowed again + if !rl.Allow(clientAddr) { + t.Errorf("Expected request after 1m1s to be allowed") + } + + // Another client should not be affected + if !rl.Allow("192.168.1.2:5678") { + t.Errorf("Expected request from different client to be allowed") + } +} + +func TestJobRateLimiter_FallbackTime(t *testing.T) { + rl := NewJobRateLimiter(3) + rl.now = nil // Force the nil branch in Allow + + if !rl.Allow("127.0.0.1") { + t.Errorf("Expected request to be allowed with fallback time") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 84ab1c9..f81733c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,7 @@ import ( "github.com/adcondev/poster/pkg/connection" "github.com/adcondev/ticket-daemon/internal/config" "github.com/adcondev/ticket-daemon/internal/posprinter" + "github.com/adcondev/ticket-daemon/internal/utils" ) const maxJobsPerMinute = 30 @@ -46,7 +47,6 @@ type Message struct { Tipo string `json:"tipo"` ID string `json:"id,omitempty"` Datos json.RawMessage `json:"datos,omitempty"` - //nolint:gosec // Required JSON key for client payload AuthToken string `json:"auth_token,omitempty"` } @@ -188,7 +188,7 @@ func (s *Server) handleTicket(ctx context.Context, conn *websocket.Conn, msg *Me // ── RATE LIMIT ──────────────────────────────────────── clientAddr := fmt.Sprintf("%p", conn) // Using pointer address as client identifier if !s.jobLimiter.Allow(clientAddr) { - log.Printf("[AUDIT] JOB_RATE_LIMITED | client=%s", clientAddr) + log.Printf("[AUDIT] JOB_RATE_LIMITED | client=%s", utils.SanitizeLog(clientAddr)) s.sendError(ctx, conn, jobID, "Rate limit exceeded: please wait before submitting more jobs") return } @@ -197,7 +197,7 @@ func (s *Server) handleTicket(ctx context.Context, conn *websocket.Conn, msg *Me if config.AuthToken != "" { token := msg.AuthToken if token != config.AuthToken { - log.Printf("[AUDIT] JOB_REJECTED | reason=invalid_token | client=%s", clientAddr) + log.Printf("[AUDIT] JOB_REJECTED | reason=invalid_token | client=%s", utils.SanitizeLog(clientAddr)) s.sendError(ctx, conn, jobID, "Invalid or missing auth token") return }