mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +08:00
security: login rate limiting, fix health endpoint, harden error handling
- Fix health endpoint returning null time (use time.Now() instead of context value) - Add LoginRateLimiter: 5 attempts per 15min window, 5min block on brute-force - Apply rate limiting to login/register endpoints - Move reset-password to authenticated routes (was unauthenticated — critical vuln) - Limit response body sizes on proxy/external API calls (prevent memory exhaustion) - Sanitize error messages in agent chat endpoint (don't leak internal errors) - Record login success/failure for rate limiting tracking
This commit is contained in:
@@ -66,7 +66,7 @@ func (b *Brain) scanNews(seen map[string]bool) {
|
|||||||
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
|
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
|
||||||
if err != nil { return }
|
if err != nil { return }
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB limit
|
||||||
if err != nil { return }
|
if err != nil { return }
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
@@ -138,7 +138,7 @@ func (b *Brain) sendBrief(hour int) {
|
|||||||
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
|
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
|
||||||
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
|
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
|
||||||
if err != nil { continue }
|
if err != nil { continue }
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // 64KB limit
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if readErr != nil { continue }
|
if readErr != nil { continue }
|
||||||
var t map[string]string
|
var t map[string]string
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ func fetchStockQuote(code string) (*StockQuote, error) {
|
|||||||
if err != nil { return nil, err }
|
if err != nil { return nil, err }
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
reader := transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder())
|
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
|
||||||
body, err := io.ReadAll(reader)
|
body, err := io.ReadAll(reader)
|
||||||
if err != nil { return nil, err }
|
if err != nil { return nil, err }
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
|
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(rw, 500, map[string]string{"error": err.Error()})
|
w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID)
|
||||||
|
writeJSON(rw, 500, map[string]string{"error": "Failed to process message. Please try again."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(rw, 200, map[string]string{"response": resp})
|
writeJSON(rw, 200, map[string]string{"response": resp})
|
||||||
@@ -107,13 +108,14 @@ func proxyBinance(rw http.ResponseWriter, url string) {
|
|||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(rw, 502, map[string]string{"error": err.Error()})
|
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
rw.Header().Set("Content-Type", "application/json")
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
rw.Header().Set("Access-Control-Allow-Origin", "*")
|
rw.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
io.Copy(rw, resp.Body)
|
// Limit response body to 2MB to prevent memory exhaustion
|
||||||
|
io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ func (s *Server) handleLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||||
|
// Record failed attempt for rate limiting
|
||||||
|
if s.loginLimiter != nil {
|
||||||
|
s.loginLimiter.RecordFailure(c.ClientIP())
|
||||||
|
}
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,6 +151,11 @@ func (s *Server) handleLogin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear rate limit on success
|
||||||
|
if s.loginLimiter != nil {
|
||||||
|
s.loginLimiter.RecordSuccess(c.ClientIP())
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"token": token,
|
"token": token,
|
||||||
"user_id": user.ID,
|
"user_id": user.ID,
|
||||||
|
|||||||
132
api/rate_limiter.go
Normal file
132
api/rate_limiter.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginAttempt tracks failed login attempts per IP
|
||||||
|
type loginAttempt struct {
|
||||||
|
count int
|
||||||
|
lastTry time.Time
|
||||||
|
blockedUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRateLimiter protects login endpoint from brute-force attacks
|
||||||
|
type LoginRateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
attempts map[string]*loginAttempt
|
||||||
|
|
||||||
|
maxAttempts int // max failures before blocking
|
||||||
|
blockTime time.Duration // how long to block after max failures
|
||||||
|
windowTime time.Duration // time window for counting failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoginRateLimiter creates a rate limiter for login attempts
|
||||||
|
func NewLoginRateLimiter() *LoginRateLimiter {
|
||||||
|
rl := &LoginRateLimiter{
|
||||||
|
attempts: make(map[string]*loginAttempt),
|
||||||
|
maxAttempts: 5,
|
||||||
|
blockTime: 5 * time.Minute,
|
||||||
|
windowTime: 15 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale entries every 10 minutes
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(10 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
rl.cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check returns true if the IP is allowed to attempt login
|
||||||
|
func (rl *LoginRateLimiter) Check(ip string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
a, exists := rl.attempts[ip]
|
||||||
|
if !exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if still blocked
|
||||||
|
if !a.blockedUntil.IsZero() && time.Now().Before(a.blockedUntil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window expired — reset
|
||||||
|
if time.Since(a.lastTry) > rl.windowTime {
|
||||||
|
delete(rl.attempts, ip)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.count < rl.maxAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure records a failed login attempt
|
||||||
|
func (rl *LoginRateLimiter) RecordFailure(ip string) {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
a, exists := rl.attempts[ip]
|
||||||
|
if !exists {
|
||||||
|
a = &loginAttempt{}
|
||||||
|
rl.attempts[ip] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset if window expired
|
||||||
|
if time.Since(a.lastTry) > rl.windowTime {
|
||||||
|
a.count = 0
|
||||||
|
a.blockedUntil = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.count++
|
||||||
|
a.lastTry = time.Now()
|
||||||
|
|
||||||
|
if a.count >= rl.maxAttempts {
|
||||||
|
a.blockedUntil = time.Now().Add(rl.blockTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess clears failed attempts for an IP after successful login
|
||||||
|
func (rl *LoginRateLimiter) RecordSuccess(ip string) {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
delete(rl.attempts, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup removes stale entries
|
||||||
|
func (rl *LoginRateLimiter) cleanup() {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for ip, a := range rl.attempts {
|
||||||
|
// Remove if both window and block have expired
|
||||||
|
if now.Sub(a.lastTry) > rl.windowTime && (a.blockedUntil.IsZero() || now.After(a.blockedUntil)) {
|
||||||
|
delete(rl.attempts, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRateLimitMiddleware creates a gin middleware for login rate limiting
|
||||||
|
func LoginRateLimitMiddleware(rl *LoginRateLimiter) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
if !rl.Check(ip) {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||||
|
"error": "Too many login attempts. Please try again later.",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type Server struct {
|
|||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
port int
|
port int
|
||||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||||
|
loginLimiter *LoginRateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer Creates API server
|
// NewServer Creates API server
|
||||||
@@ -41,11 +42,12 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
|||||||
cryptoHandler := NewCryptoHandler(cryptoService)
|
cryptoHandler := NewCryptoHandler(cryptoService)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
router: router,
|
router: router,
|
||||||
traderManager: traderManager,
|
traderManager: traderManager,
|
||||||
store: st,
|
store: st,
|
||||||
cryptoHandler: cryptoHandler,
|
cryptoHandler: cryptoHandler,
|
||||||
port: port,
|
port: port,
|
||||||
|
loginLimiter: NewLoginRateLimiter(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
@@ -111,10 +113,12 @@ func (s *Server) setupRoutes() {
|
|||||||
// Public strategy market (no authentication required)
|
// Public strategy market (no authentication required)
|
||||||
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
||||||
|
|
||||||
// Authentication related routes (no authentication required)
|
// Authentication related routes (no authentication required, rate-limited)
|
||||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
authLimited := api.Group("/", LoginRateLimitMiddleware(s.loginLimiter))
|
||||||
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
s.route(authLimited, "POST", "/register", "Register new user", s.handleRegister)
|
||||||
s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword)
|
s.route(authLimited, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||||
|
// NOTE: reset-password requires authentication since we have no email verification.
|
||||||
|
// This prevents unauthenticated password reset attacks.
|
||||||
|
|
||||||
// Routes requiring authentication
|
// Routes requiring authentication
|
||||||
protected := api.Group("/", s.authMiddleware())
|
protected := api.Group("/", s.authMiddleware())
|
||||||
@@ -126,6 +130,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
||||||
`Body: {"new_password":"<string, min 8 chars>"}`,
|
`Body: {"new_password":"<string, min 8 chars>"}`,
|
||||||
s.handleChangePassword)
|
s.handleChangePassword)
|
||||||
|
s.route(protected, "POST", "/reset-password", "Reset password (requires authentication)", s.handleResetPassword)
|
||||||
|
|
||||||
// Server IP query (requires authentication, for whitelist configuration)
|
// Server IP query (requires authentication, for whitelist configuration)
|
||||||
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
|
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
|
||||||
@@ -355,7 +360,7 @@ Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_
|
|||||||
func (s *Server) handleHealth(c *gin.Context) {
|
func (s *Server) handleHealth(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"time": c.Request.Context().Value("time"),
|
"time": time.Now().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user