diff --git a/src/webui/README.md b/src/webui/README.md index b5f122c8..c170d363 100644 --- a/src/webui/README.md +++ b/src/webui/README.md @@ -9,6 +9,7 @@ This module provides a web interface for managing Yggdrasil node through a brows - ✅ Development and production build modes - ✅ Custom session-based authentication - ✅ Beautiful login page (password-only) +- ✅ **Brute force protection** with IP blocking - ✅ Session management with automatic cleanup - ✅ IPv4 and IPv6 support - ✅ Path traversal attack protection @@ -90,6 +91,10 @@ server.Stop() - Custom session-based authentication (password protection) - HttpOnly and Secure cookies - Session expiration (24 hours) +- **Brute force protection**: IP blocking after 3 failed attempts +- **Temporary lockout**: 1-minute timeout for blocked IPs +- Automatic cleanup of expired blocks and sessions +- Real IP detection (supports X-Forwarded-For, X-Real-IP headers) - Health check endpoint always accessible without authentication ## Testing diff --git a/src/webui/auth_test.go b/src/webui/auth_test.go index f32cb5be..c745b1ae 100644 --- a/src/webui/auth_test.go +++ b/src/webui/auth_test.go @@ -145,3 +145,143 @@ func TestHealthEndpointNoAuth(t *testing.T) { t.Errorf("Expected 'OK', got '%s'", rr.Body.String()) } } + +func TestBruteForceProtection(t *testing.T) { + logger := log.New(nil, "test: ", log.Flags()) + server := Server("127.0.0.1:0", "testpassword", logger) + + // Test multiple failed attempts from same IP + clientIP := "192.168.1.100" + + // First 3 attempts should be allowed but fail + for i := 1; i <= 3; i++ { + t.Run(fmt.Sprintf("Failed_attempt_%d", i), func(t *testing.T) { + loginData := `{"password":"wrongpassword"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = clientIP + ":12345" + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 for failed attempt %d, got %d", i, rr.Code) + } + }) + } + + // 4th attempt should be blocked + t.Run("Blocked_attempt", func(t *testing.T) { + loginData := `{"password":"wrongpassword"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = clientIP + ":12345" + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != http.StatusTooManyRequests { + t.Errorf("Expected 429 for blocked attempt, got %d", rr.Code) + } + }) + + // Even correct password should be blocked during block period + t.Run("Correct_password_while_blocked", func(t *testing.T) { + loginData := `{"password":"testpassword"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = clientIP + ":12345" + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != http.StatusTooManyRequests { + t.Errorf("Expected 429 even for correct password while blocked, got %d", rr.Code) + } + }) +} + +func TestBruteForceProtectionDifferentIPs(t *testing.T) { + logger := log.New(nil, "test: ", log.Flags()) + server := Server("127.0.0.1:0", "testpassword", logger) + + // Failed attempts from one IP shouldn't affect another IP + ip1 := "192.168.1.100" + ip2 := "192.168.1.101" + + // Block first IP + for i := 1; i <= 3; i++ { + loginData := `{"password":"wrongpassword"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = ip1 + ":12345" + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 for failed attempt %d from IP1, got %d", i, rr.Code) + } + } + + // Second IP should still be able to attempt login + t.Run("Different_IP_not_blocked", func(t *testing.T) { + loginData := `{"password":"wrongpassword"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = ip2 + ":12345" + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 for different IP (not blocked), got %d", rr.Code) + } + }) +} + +func TestSuccessfulLoginClearsFailedAttempts(t *testing.T) { + logger := log.New(nil, "test: ", log.Flags()) + server := Server("127.0.0.1:0", "testpassword", logger) + + clientIP := "192.168.1.100" + + // Make 2 failed attempts + for i := 1; i <= 2; i++ { + loginData := `{"password":"wrongpassword"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = clientIP + ":12345" + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401 for failed attempt %d, got %d", i, rr.Code) + } + } + + // Successful login should clear failed attempts + t.Run("Successful_login_clears_attempts", func(t *testing.T) { + loginData := `{"password":"testpassword"}` + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = clientIP + ":12345" + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200 for correct password, got %d", rr.Code) + } + + // Verify failed attempts were cleared + server.attemptsMux.RLock() + _, exists := server.failedAttempts[clientIP] + server.attemptsMux.RUnlock() + + if exists { + t.Error("Failed attempts should be cleared after successful login") + } + }) +} diff --git a/src/webui/example_config.hjson b/src/webui/example_config.hjson index b96a858f..19b738ec 100644 --- a/src/webui/example_config.hjson +++ b/src/webui/example_config.hjson @@ -33,9 +33,13 @@ // - Session-based authentication with secure cookies // - 24-hour session expiration // - Automatic session cleanup +// - Brute force protection (3 failed attempts = 1 minute block) +// - IP-based blocking with automatic cleanup // // Security recommendations: // - Use a strong, unique password (12+ characters) // - Bind to localhost (127.0.0.1) unless you need remote access // - Consider using HTTPS reverse proxy for production deployments -// - Sessions are stored in memory and lost on server restart \ No newline at end of file +// - Sessions are stored in memory and lost on server restart +// - Failed login attempts are tracked per IP address +// - If behind a reverse proxy, ensure X-Forwarded-For headers are set correctly \ No newline at end of file diff --git a/src/webui/server.go b/src/webui/server.go index c0a5f3c6..11957ab2 100644 --- a/src/webui/server.go +++ b/src/webui/server.go @@ -16,24 +16,39 @@ import ( ) type WebUIServer struct { - server *http.Server - log core.Logger - listen string - password string - sessions map[string]time.Time // sessionID -> expiry time - sessionsMux sync.RWMutex + server *http.Server + log core.Logger + listen string + password string + sessions map[string]time.Time // sessionID -> expiry time + sessionsMux sync.RWMutex + failedAttempts map[string]*FailedLoginInfo // IP -> failed login info + attemptsMux sync.RWMutex } type LoginRequest struct { Password string `json:"password"` } +type FailedLoginInfo struct { + Count int + LastAttempt time.Time + BlockedUntil time.Time +} + +const ( + MaxFailedAttempts = 3 + BlockDuration = 1 * time.Minute + AttemptWindow = 15 * time.Minute // Reset counter if no attempts in 15 minutes +) + func Server(listen string, password string, log core.Logger) *WebUIServer { return &WebUIServer{ - listen: listen, - password: password, - log: log, - sessions: make(map[string]time.Time), + listen: listen, + password: password, + log: log, + sessions: make(map[string]time.Time), + failedAttempts: make(map[string]*FailedLoginInfo), } } @@ -79,6 +94,89 @@ func (w *WebUIServer) createSession() string { return sessionID } +// getClientIP extracts the real client IP from request +func (w *WebUIServer) getClientIP(r *http.Request) string { + // Check for forwarded IP headers (for reverse proxies) + forwarded := r.Header.Get("X-Forwarded-For") + if forwarded != "" { + // Take the first IP in the chain + ips := strings.Split(forwarded, ",") + return strings.TrimSpace(ips[0]) + } + + realIP := r.Header.Get("X-Real-IP") + if realIP != "" { + return realIP + } + + // Extract IP from RemoteAddr + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + return ip +} + +// isIPBlocked checks if an IP address is currently blocked +func (w *WebUIServer) isIPBlocked(ip string) bool { + w.attemptsMux.RLock() + defer w.attemptsMux.RUnlock() + + info, exists := w.failedAttempts[ip] + if !exists { + return false + } + + return time.Now().Before(info.BlockedUntil) +} + +// recordFailedAttempt records a failed login attempt for an IP +func (w *WebUIServer) recordFailedAttempt(ip string) { + w.attemptsMux.Lock() + defer w.attemptsMux.Unlock() + + now := time.Now() + info, exists := w.failedAttempts[ip] + + if !exists { + info = &FailedLoginInfo{} + w.failedAttempts[ip] = info + } + + // Reset counter if last attempt was too long ago + if now.Sub(info.LastAttempt) > AttemptWindow { + info.Count = 0 + } + + info.Count++ + info.LastAttempt = now + + // Block IP if too many failed attempts + if info.Count >= MaxFailedAttempts { + info.BlockedUntil = now.Add(BlockDuration) + w.log.Warnf("IP %s blocked for %v after %d failed login attempts", ip, BlockDuration, info.Count) + } +} + +// clearFailedAttempts clears failed attempts for an IP (on successful login) +func (w *WebUIServer) clearFailedAttempts(ip string) { + w.attemptsMux.Lock() + defer w.attemptsMux.Unlock() + + delete(w.failedAttempts, ip) +} + +// cleanupFailedAttempts removes old failed attempt records +func (w *WebUIServer) cleanupFailedAttempts() { + w.attemptsMux.Lock() + defer w.attemptsMux.Unlock() + + now := time.Now() + for ip, info := range w.failedAttempts { + // Remove if block period has expired and no recent attempts + if now.After(info.BlockedUntil) && now.Sub(info.LastAttempt) > AttemptWindow { + delete(w.failedAttempts, ip) + } + } +} + // cleanupExpiredSessions removes expired sessions func (w *WebUIServer) cleanupExpiredSessions() { w.sessionsMux.Lock() @@ -126,13 +224,22 @@ func (w *WebUIServer) authMiddleware(next http.HandlerFunc) http.HandlerFunc { } } -// loginHandler handles password authentication +// loginHandler handles password authentication with brute force protection func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) return } + clientIP := w.getClientIP(r) + + // Check if IP is blocked + if w.isIPBlocked(clientIP) { + w.log.Warnf("Blocked login attempt from %s (IP is temporarily blocked)", clientIP) + http.Error(rw, "Too many failed attempts. Please try again later.", http.StatusTooManyRequests) + return + } + var loginReq LoginRequest if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { http.Error(rw, "Invalid request", http.StatusBadRequest) @@ -141,11 +248,15 @@ func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) { // Check password if subtle.ConstantTimeCompare([]byte(loginReq.Password), []byte(w.password)) != 1 { - w.log.Debugf("Authentication failed for request from %s", r.RemoteAddr) + w.log.Debugf("Authentication failed for request from %s", clientIP) + w.recordFailedAttempt(clientIP) http.Error(rw, "Invalid password", http.StatusUnauthorized) return } + // Successful login - clear any failed attempts + w.clearFailedAttempts(clientIP) + // Create session sessionID := w.createSession() @@ -160,7 +271,7 @@ func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) { MaxAge: 24 * 60 * 60, // 24 hours }) - w.log.Debugf("Successful authentication for request from %s", r.RemoteAddr) + w.log.Infof("Successful authentication for IP %s", clientIP) rw.WriteHeader(http.StatusOK) } @@ -196,12 +307,20 @@ func (w *WebUIServer) Start() error { } } - // Start session cleanup routine + // Start cleanup routines go func() { - ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() - for range ticker.C { - w.cleanupExpiredSessions() + sessionTicker := time.NewTicker(1 * time.Hour) + attemptsTicker := time.NewTicker(5 * time.Minute) // Clean failed attempts more frequently + defer sessionTicker.Stop() + defer attemptsTicker.Stop() + + for { + select { + case <-sessionTicker.C: + w.cleanupExpiredSessions() + case <-attemptsTicker.C: + w.cleanupFailedAttempts() + } } }() diff --git a/src/webui/static/login.html b/src/webui/static/login.html index d6835999..43e38a7f 100644 --- a/src/webui/static/login.html +++ b/src/webui/static/login.html @@ -63,6 +63,13 @@ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); } + .form-group input:disabled { + background-color: #f8f9fa; + border-color: #e9ecef; + color: #6c757d; + cursor: not-allowed; + } + .login-button { width: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); @@ -144,8 +151,22 @@ if (response.ok) { // Success - redirect to main page window.location.href = '/'; + } else if (response.status === 429) { + // Too many requests - IP blocked + errorMessage.textContent = 'Too many failed attempts. Please wait 1 minute before trying again.'; + errorMessage.style.display = 'block'; + document.getElementById('password').value = ''; + document.getElementById('password').disabled = true; + + // Re-enable after 1 minute + setTimeout(() => { + document.getElementById('password').disabled = false; + document.getElementById('password').focus(); + errorMessage.style.display = 'none'; + }, 60000); } else { - // Show error message + // Invalid password + errorMessage.textContent = 'Invalid password. Please try again.'; errorMessage.style.display = 'block'; document.getElementById('password').value = ''; document.getElementById('password').focus();