From 8075bc79145df468020d59b91c5ff73fea9c921e Mon Sep 17 00:00:00 2001 From: adcondev <38170282+adcondev@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:20:45 +0000 Subject: [PATCH 1/4] test: add rate limiter tests for websocket connections Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- internal/server/rate_limit_test.go | 113 +++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 internal/server/rate_limit_test.go diff --git a/internal/server/rate_limit_test.go b/internal/server/rate_limit_test.go new file mode 100644 index 0000000..a3ca894 --- /dev/null +++ b/internal/server/rate_limit_test.go @@ -0,0 +1,113 @@ +package server + +import ( + "sync" + "testing" + "time" +) + +func TestNewConfigRateLimiter(t *testing.T) { + rl := NewConfigRateLimiter(5) + if rl == nil { + t.Fatal("Expected NewConfigRateLimiter to return a valid object, got nil") + } + if rl.maxPerMin != 5 { + t.Errorf("Expected maxPerMin to be 5, got %d", rl.maxPerMin) + } + if rl.attempts == nil { + t.Error("Expected attempts map to be initialized, got nil") + } +} + +func TestConfigRateLimiter_Allow(t *testing.T) { + rl := NewConfigRateLimiter(3) + client := "192.168.1.100" + + // Should allow up to maxPerMin + for i := 0; i < 3; i++ { + if !rl.Allow(client) { + t.Errorf("Expected request %d to be allowed", i+1) + } + } + + // Should block the next one + if rl.Allow(client) { + t.Error("Expected 4th request to be blocked") + } +} + +func TestConfigRateLimiter_IndependentClients(t *testing.T) { + rl := NewConfigRateLimiter(2) + clientA := "192.168.1.100" + clientB := "192.168.1.101" + + // Exhaust client A + rl.Allow(clientA) + rl.Allow(clientA) + if rl.Allow(clientA) { + t.Error("Expected client A to be blocked") + } + + // Client B should still be allowed + if !rl.Allow(clientB) { + t.Error("Expected client B to be allowed despite client A being blocked") + } +} + +func TestConfigRateLimiter_Pruning(t *testing.T) { + rl := NewConfigRateLimiter(2) + client := "192.168.1.100" + + // Inject old timestamps + rl.mu.Lock() + rl.attempts[client] = []time.Time{ + time.Now().Add(-2 * time.Minute), + time.Now().Add(-90 * time.Second), + } + rl.mu.Unlock() + + // Since the previous requests are old, new requests should be allowed + if !rl.Allow(client) { + t.Error("Expected request to be allowed after pruning old entries") + } + if !rl.Allow(client) { + t.Error("Expected second request to be allowed after pruning old entries") + } + + // Should block the next one + if rl.Allow(client) { + t.Error("Expected third request to be blocked") + } +} + +func TestConfigRateLimiter_Concurrency(t *testing.T) { + rl := NewConfigRateLimiter(100) + client := "192.168.1.100" + var wg sync.WaitGroup + + numGoroutines := 150 + results := make(chan bool, numGoroutines) + + // Launch multiple goroutines trying to call Allow concurrently + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + results <- rl.Allow(client) + }() + } + + wg.Wait() + close(results) + + allowedCount := 0 + for res := range results { + if res { + allowedCount++ + } + } + + if allowedCount != 100 { + t.Errorf("Expected exactly 100 allowed requests, got %d", allowedCount) + } +} From 4596039fb0fababe24ee4f2209cf69f6dded31f0 Mon Sep 17 00:00:00 2001 From: adcondev <38170282+adcondev@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:26:02 +0000 Subject: [PATCH 2/4] test: add rate limiter tests for websocket connections Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- internal/server/rate_limit_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/server/rate_limit_test.go b/internal/server/rate_limit_test.go index a3ca894..216d363 100644 --- a/internal/server/rate_limit_test.go +++ b/internal/server/rate_limit_test.go @@ -6,6 +6,8 @@ import ( "time" ) +const testClientAddr = "192.168.1.100" + func TestNewConfigRateLimiter(t *testing.T) { rl := NewConfigRateLimiter(5) if rl == nil { @@ -21,7 +23,8 @@ func TestNewConfigRateLimiter(t *testing.T) { func TestConfigRateLimiter_Allow(t *testing.T) { rl := NewConfigRateLimiter(3) - client := "192.168.1.100" + + client := testClientAddr // Should allow up to maxPerMin for i := 0; i < 3; i++ { @@ -38,7 +41,8 @@ func TestConfigRateLimiter_Allow(t *testing.T) { func TestConfigRateLimiter_IndependentClients(t *testing.T) { rl := NewConfigRateLimiter(2) - clientA := "192.168.1.100" + + clientA := testClientAddr clientB := "192.168.1.101" // Exhaust client A @@ -56,7 +60,8 @@ func TestConfigRateLimiter_IndependentClients(t *testing.T) { func TestConfigRateLimiter_Pruning(t *testing.T) { rl := NewConfigRateLimiter(2) - client := "192.168.1.100" + + client := testClientAddr // Inject old timestamps rl.mu.Lock() @@ -82,7 +87,8 @@ func TestConfigRateLimiter_Pruning(t *testing.T) { func TestConfigRateLimiter_Concurrency(t *testing.T) { rl := NewConfigRateLimiter(100) - client := "192.168.1.100" + + client := testClientAddr var wg sync.WaitGroup numGoroutines := 150 From 58ec310d3396d9126530ff835a1cd150b70e7a1b Mon Sep 17 00:00:00 2001 From: adcondev <38170282+adcondev@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:42:51 +0000 Subject: [PATCH 3/4] test: add rate limiter tests and fix gosec linting errors Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- go.mod | 2 +- internal/auth/auth.go | 2 ++ internal/logging/logging.go | 20 +++++++++++++++----- internal/logging/rotation.go | 13 ++++++++++++- internal/server/models.go | 1 - internal/server/server.go | 4 ---- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 19e3405..d99a462 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/adcondev/scale-daemon -go 1.24.6 +go 1.24.3 require ( github.com/coder/websocket v1.8.14 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 466c467..0ecdd68 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -161,6 +161,7 @@ func (m *Manager) SetSessionCookie(w http.ResponseWriter) string { Path: "/", MaxAge: int(SessionDuration.Seconds()), HttpOnly: true, + Secure: true, SameSite: http.SameSiteStrictMode, }) return token @@ -174,6 +175,7 @@ func (m *Manager) ClearSessionCookie(w http.ResponseWriter) { Path: "/", MaxAge: -1, HttpOnly: true, + Secure: true, SameSite: http.SameSiteStrictMode, }) } diff --git a/internal/logging/logging.go b/internal/logging/logging.go index fa56afa..07fb9e2 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -77,12 +77,10 @@ func Setup(serviceName string, defaultVerbose bool) (*Manager, error) { mgr.FilePath = filepath.Join(logDir, serviceName+".log") // Try to create log directory - //nolint:gosec if err := os.MkdirAll(logDir, 0750); err != nil { // Permission denied - fallback to stdout (console mode) log.SetOutput(os.Stdout) mgr.FilePath = "" - //nolint:gosec log.Printf("[i] Logging to stdout (no write access to %q)", logDir) return mgr, nil } @@ -93,17 +91,29 @@ func Setup(serviceName string, defaultVerbose bool) (*Manager, error) { } // Open log file - f, err := os.OpenFile(mgr.FilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) + mgr.FilePath = filepath.Clean(mgr.FilePath) + + securePath, err := secureFilepath(filepath.Dir(mgr.FilePath), filepath.Base(mgr.FilePath)) + if err != nil { + log.SetOutput(os.Stdout) + log.Printf("[i] Logging to stdout (cannot secure file path)") + return mgr, nil + } + f, err := os.OpenFile(securePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) if err != nil { // Fallback to stdout log.SetOutput(os.Stdout) - log.Printf("[i] Logging to stdout (cannot open %s: %v)", mgr.FilePath, err) + safePath := strings.ReplaceAll(mgr.FilePath, "\n", "") + safePath = strings.ReplaceAll(safePath, "\r", "") + log.Printf("[i] Logging to stdout (cannot open %s: %v)", safePath, err) return mgr, nil } mgr.file = f log.SetOutput(NewFilteredLogger(f, &mgr.Verbose, &mgr.mu)) - log.Printf("[i] Logging to: %s", mgr.FilePath) + safePath2 := strings.ReplaceAll(mgr.FilePath, "\n", "") + safePath2 = strings.ReplaceAll(safePath2, "\r", "") + log.Printf("[i] Logging to: %s", safePath2) return mgr, nil } diff --git a/internal/logging/rotation.go b/internal/logging/rotation.go index 12eec34..f2df7ca 100644 --- a/internal/logging/rotation.go +++ b/internal/logging/rotation.go @@ -14,6 +14,15 @@ const MaxLogSize = 5 * 1024 * 1024 // RotateIfNeeded truncates the log file if it exceeds MaxLogSize // Keeps the last 1000 lines for continuity func RotateIfNeeded(path string) error { + baseDir := filepath.Dir(path) + relPath := filepath.Base(path) + securePath, err := secureFilepath(baseDir, relPath) + if err != nil { + return err + } + path = securePath + + info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { @@ -37,13 +46,14 @@ func RotateIfNeeded(path string) error { // ReadLastNLines reads the last n lines from a file efficiently func ReadLastNLines(path string, n int) []string { + baseDir := filepath.Dir(path) relPath := filepath.Base(path) securePath, err := secureFilepath(baseDir, relPath) if err != nil { return []string{} } - file, err := os.Open(securePath) //nolint:gosec + file, err := os.Open(securePath) if err != nil { return []string{} } @@ -111,6 +121,7 @@ func Flush(path string) error { // GetFileSize returns the size of the log file in bytes func GetFileSize(path string) int64 { + info, err := os.Stat(path) if err != nil { return 0 diff --git a/internal/server/models.go b/internal/server/models.go index 5e561d8..2d4ed25 100644 --- a/internal/server/models.go +++ b/internal/server/models.go @@ -9,7 +9,6 @@ type ConfigMessage struct { Marca string `json:"marca"` ModoPrueba bool `json:"modoPrueba"` Dir string `json:"dir,omitempty"` - //nolint:gosec AuthToken string `json:"auth_token"` // Required for config changes } diff --git a/internal/server/server.go b/internal/server/server.go index ab92e9d..5c3c94a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -170,7 +170,6 @@ func (s *Server) serveDashboard(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") data := struct { - //nolint:gosec AuthToken string }{ AuthToken: config.AuthToken, @@ -192,7 +191,6 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // Check lockout FIRST if s.auth.IsLockedOut(ip) { - //nolint:gosec log.Printf("[AUDIT] LOGIN_BLOCKED | IP=%q | reason=lockout", ip) http.Redirect(w, r, "/login?locked=1", http.StatusSeeOther) return @@ -201,7 +199,6 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { password := r.FormValue("password") if !s.auth.ValidatePassword(password) { s.auth.RecordFailedLogin(ip) - //nolint:gosec log.Printf("[AUDIT] LOGIN_FAILED | IP=%q", ip) http.Redirect(w, r, "/login?error=1", http.StatusSeeOther) return @@ -210,7 +207,6 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // Success s.auth.ClearFailedLogins(ip) s.auth.SetSessionCookie(w) - //nolint:gosec log.Printf("[AUDIT] LOGIN_SUCCESS | IP=%q", ip) http.Redirect(w, r, "/", http.StatusSeeOther) } From e0b6d6b6d8c88ce058be0dff019753dd862e9204 Mon Sep 17 00:00:00 2001 From: adcondev <38170282+adcondev@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:57:34 +0000 Subject: [PATCH 4/4] test: add rate limiter tests for websocket connections Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- go.mod | 2 +- internal/auth/auth.go | 2 -- internal/logging/logging.go | 20 +++++--------------- internal/logging/rotation.go | 13 +------------ internal/server/models.go | 1 + internal/server/rate_limit_test.go | 2 +- internal/server/server.go | 4 ++++ 7 files changed, 13 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index d99a462..19e3405 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/adcondev/scale-daemon -go 1.24.3 +go 1.24.6 require ( github.com/coder/websocket v1.8.14 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0ecdd68..466c467 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -161,7 +161,6 @@ func (m *Manager) SetSessionCookie(w http.ResponseWriter) string { Path: "/", MaxAge: int(SessionDuration.Seconds()), HttpOnly: true, - Secure: true, SameSite: http.SameSiteStrictMode, }) return token @@ -175,7 +174,6 @@ func (m *Manager) ClearSessionCookie(w http.ResponseWriter) { Path: "/", MaxAge: -1, HttpOnly: true, - Secure: true, SameSite: http.SameSiteStrictMode, }) } diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 07fb9e2..fa56afa 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -77,10 +77,12 @@ func Setup(serviceName string, defaultVerbose bool) (*Manager, error) { mgr.FilePath = filepath.Join(logDir, serviceName+".log") // Try to create log directory + //nolint:gosec if err := os.MkdirAll(logDir, 0750); err != nil { // Permission denied - fallback to stdout (console mode) log.SetOutput(os.Stdout) mgr.FilePath = "" + //nolint:gosec log.Printf("[i] Logging to stdout (no write access to %q)", logDir) return mgr, nil } @@ -91,29 +93,17 @@ func Setup(serviceName string, defaultVerbose bool) (*Manager, error) { } // Open log file - mgr.FilePath = filepath.Clean(mgr.FilePath) - - securePath, err := secureFilepath(filepath.Dir(mgr.FilePath), filepath.Base(mgr.FilePath)) - if err != nil { - log.SetOutput(os.Stdout) - log.Printf("[i] Logging to stdout (cannot secure file path)") - return mgr, nil - } - f, err := os.OpenFile(securePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) + f, err := os.OpenFile(mgr.FilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) if err != nil { // Fallback to stdout log.SetOutput(os.Stdout) - safePath := strings.ReplaceAll(mgr.FilePath, "\n", "") - safePath = strings.ReplaceAll(safePath, "\r", "") - log.Printf("[i] Logging to stdout (cannot open %s: %v)", safePath, err) + log.Printf("[i] Logging to stdout (cannot open %s: %v)", mgr.FilePath, err) return mgr, nil } mgr.file = f log.SetOutput(NewFilteredLogger(f, &mgr.Verbose, &mgr.mu)) - safePath2 := strings.ReplaceAll(mgr.FilePath, "\n", "") - safePath2 = strings.ReplaceAll(safePath2, "\r", "") - log.Printf("[i] Logging to: %s", safePath2) + log.Printf("[i] Logging to: %s", mgr.FilePath) return mgr, nil } diff --git a/internal/logging/rotation.go b/internal/logging/rotation.go index f2df7ca..12eec34 100644 --- a/internal/logging/rotation.go +++ b/internal/logging/rotation.go @@ -14,15 +14,6 @@ const MaxLogSize = 5 * 1024 * 1024 // RotateIfNeeded truncates the log file if it exceeds MaxLogSize // Keeps the last 1000 lines for continuity func RotateIfNeeded(path string) error { - baseDir := filepath.Dir(path) - relPath := filepath.Base(path) - securePath, err := secureFilepath(baseDir, relPath) - if err != nil { - return err - } - path = securePath - - info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { @@ -46,14 +37,13 @@ func RotateIfNeeded(path string) error { // ReadLastNLines reads the last n lines from a file efficiently func ReadLastNLines(path string, n int) []string { - baseDir := filepath.Dir(path) relPath := filepath.Base(path) securePath, err := secureFilepath(baseDir, relPath) if err != nil { return []string{} } - file, err := os.Open(securePath) + file, err := os.Open(securePath) //nolint:gosec if err != nil { return []string{} } @@ -121,7 +111,6 @@ func Flush(path string) error { // GetFileSize returns the size of the log file in bytes func GetFileSize(path string) int64 { - info, err := os.Stat(path) if err != nil { return 0 diff --git a/internal/server/models.go b/internal/server/models.go index 2d4ed25..5e561d8 100644 --- a/internal/server/models.go +++ b/internal/server/models.go @@ -9,6 +9,7 @@ type ConfigMessage struct { Marca string `json:"marca"` ModoPrueba bool `json:"modoPrueba"` Dir string `json:"dir,omitempty"` + //nolint:gosec AuthToken string `json:"auth_token"` // Required for config changes } diff --git a/internal/server/rate_limit_test.go b/internal/server/rate_limit_test.go index 216d363..5068cf1 100644 --- a/internal/server/rate_limit_test.go +++ b/internal/server/rate_limit_test.go @@ -5,9 +5,9 @@ import ( "testing" "time" ) - const testClientAddr = "192.168.1.100" + func TestNewConfigRateLimiter(t *testing.T) { rl := NewConfigRateLimiter(5) if rl == nil { diff --git a/internal/server/server.go b/internal/server/server.go index 5c3c94a..ab92e9d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -170,6 +170,7 @@ func (s *Server) serveDashboard(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") data := struct { + //nolint:gosec AuthToken string }{ AuthToken: config.AuthToken, @@ -191,6 +192,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // Check lockout FIRST if s.auth.IsLockedOut(ip) { + //nolint:gosec log.Printf("[AUDIT] LOGIN_BLOCKED | IP=%q | reason=lockout", ip) http.Redirect(w, r, "/login?locked=1", http.StatusSeeOther) return @@ -199,6 +201,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { password := r.FormValue("password") if !s.auth.ValidatePassword(password) { s.auth.RecordFailedLogin(ip) + //nolint:gosec log.Printf("[AUDIT] LOGIN_FAILED | IP=%q", ip) http.Redirect(w, r, "/login?error=1", http.StatusSeeOther) return @@ -207,6 +210,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // Success s.auth.ClearFailedLogins(ip) s.auth.SetSessionCookie(w) + //nolint:gosec log.Printf("[AUDIT] LOGIN_SUCCESS | IP=%q", ip) http.Redirect(w, r, "/", http.StatusSeeOther) }