From abb43c8c689cdd47e61f052f0735d00ed55c381c Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 16:47:23 +0800 Subject: [PATCH] fix: division-by-zero guard, rate-limit wallet endpoints, safer JSON parse, Lighter HTTP checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard GridCount-1 division by zero in grid spacing calculation (both grid.go and grid_levels.go) - Rate-limit /wallet/validate and /wallet/generate endpoints (prevent DoS on key generation) - Wrap JSON.parse(localStorage) in try-catch in AuthContext (corrupted data → graceful re-login) - Wrap handleJSONResponse JSON.parse in try-catch with descriptive error - Add HTTP status code checks for Lighter GetActiveOrders and sendTx - Replace deprecated strings.Title with cases.Title (golang.org/x/text) - Reuse HTTP client in checkClaw402Health (was creating new client per call) --- agent/onboard.go | 14 +++++++++----- api/handler_wallet.go | 5 +++-- api/server.go | 6 ++++-- trader/auto_trader_grid.go | 8 ++++++-- trader/auto_trader_grid_levels.go | 7 ++++++- trader/lighter/orders.go | 8 ++++++++ trader/lighter/trading.go | 8 ++++++++ web/src/contexts/AuthContext.tsx | 19 +++++++++++++++---- web/src/lib/api/helpers.ts | 6 +++++- 9 files changed, 64 insertions(+), 17 deletions(-) diff --git a/agent/onboard.go b/agent/onboard.go index f0695b4b..589b9c5d 100644 --- a/agent/onboard.go +++ b/agent/onboard.go @@ -4,9 +4,13 @@ import ( "fmt" "strings" + "golang.org/x/text/cases" + "golang.org/x/text/language" "nofx/store" ) +var titleCaser = cases.Title(language.English) + // Onboard handles first-time setup through natural language. // When there's no trader configured, the agent guides the user. @@ -176,9 +180,9 @@ func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupStat a.saveSetupState(userID, state) if L == "zh" { - return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key:", strings.Title(ex)), true + return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key:", titleCaser.String(ex)), true } - return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", strings.Title(ex)), true + return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true } func (a *Agent) handleAIChoice(userID int64, text string, state *SetupState, L string) (string, bool) { @@ -240,7 +244,7 @@ func (a *Agent) finishSetup(userID int64, state *SetupState, L string) (string, result = fmt.Sprintf("🎉 *配置完成!*\n\n"+ "• 交易所: %s\n"+ "• API Key: %s\n", - strings.Title(state.Exchange), maskedKey) + titleCaser.String(state.Exchange), maskedKey) if state.AIModel != "" { result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel) } @@ -249,7 +253,7 @@ func (a *Agent) finishSetup(userID int64, state *SetupState, L string) (string, result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+ "• Exchange: %s\n"+ "• API Key: %s\n", - strings.Title(state.Exchange), maskedKey) + titleCaser.String(state.Exchange), maskedKey) if state.AIModel != "" { result += fmt.Sprintf("• AI Model: %s\n", state.AIModel) } @@ -329,7 +333,7 @@ func (a *Agent) createTraderFromSetup(state *SetupState) error { // Create trader config trader := &store.Trader{ - Name: fmt.Sprintf("NOFXi-%s", strings.Title(state.Exchange)), + Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)), UserID: "default", ExchangeID: exchangeID, AIModelID: aiModelID, diff --git a/api/handler_wallet.go b/api/handler_wallet.go index dca9a1bf..620c4b6b 100644 --- a/api/handler_wallet.go +++ b/api/handler_wallet.go @@ -115,9 +115,10 @@ func (s *Server) handleWalletGenerate(c *gin.Context) { }) } +var walletHTTPClient = &http.Client{Timeout: 5 * time.Second} + func checkClaw402Health() string { - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get("https://claw402.ai/health") + resp, err := walletHTTPClient.Get("https://claw402.ai/health") if err != nil { return "unreachable" } diff --git a/api/server.go b/api/server.go index 124dc7df..d91e5955 100644 --- a/api/server.go +++ b/api/server.go @@ -144,8 +144,10 @@ func (s *Server) setupRoutes() { s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig) // Wallet validation (no authentication required — used by frontend config form) - api.POST("/wallet/validate", s.handleWalletValidate) - api.POST("/wallet/generate", s.handleWalletGenerate) + // Rate-limited to prevent DoS (key generation is computationally expensive) + walletLimited := api.Group("/", LoginRateLimitMiddleware(s.loginLimiter)) + walletLimited.POST("/wallet/validate", s.handleWalletValidate) + walletLimited.POST("/wallet/generate", s.handleWalletGenerate) // Crypto public key & config (needed by frontend before login for transport encryption) api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig) diff --git a/trader/auto_trader_grid.go b/trader/auto_trader_grid.go index 8d96829a..5c0489ff 100644 --- a/trader/auto_trader_grid.go +++ b/trader/auto_trader_grid.go @@ -326,8 +326,12 @@ func (at *AutoTrader) InitializeGrid() error { at.gridState.LowerPrice = gridConfig.LowerPrice } - // Calculate grid spacing - at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1) + // Calculate grid spacing (guard against division by zero when GridCount <= 1) + if gridConfig.GridCount > 1 { + at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1) + } else { + at.gridState.GridSpacing = 0 + } // Initialize grid levels at.initializeGridLevels(price, gridConfig) diff --git a/trader/auto_trader_grid_levels.go b/trader/auto_trader_grid_levels.go index ddc2a408..dc12cb7c 100644 --- a/trader/auto_trader_grid_levels.go +++ b/trader/auto_trader_grid_levels.go @@ -295,7 +295,12 @@ func (at *AutoTrader) autoAdjustGrid() { } // Recalculate grid spacing based on new bounds - at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1) + // Guard against division by zero when GridCount <= 1 + if gridConfig.GridCount > 1 { + at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1) + } else { + at.gridState.GridSpacing = 0 + } logger.Infof("[Grid] New bounds: $%.2f - $%.2f, spacing: $%.2f", at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing) diff --git a/trader/lighter/orders.go b/trader/lighter/orders.go index 8fe5de47..a099d94c 100644 --- a/trader/lighter/orders.go +++ b/trader/lighter/orders.go @@ -246,6 +246,14 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error return nil, fmt.Errorf("failed to read response: %w", err) } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet := string(body) + if len(snippet) > 512 { + snippet = snippet[:512] + "..." + } + return nil, fmt.Errorf("GetActiveOrders API HTTP error (status %d): %s", resp.StatusCode, snippet) + } + logger.Debugf("📋 LIGHTER GetActiveOrders raw response: %s", string(body)) // Parse response - Lighter API uses "orders" field, not "data" diff --git a/trader/lighter/trading.go b/trader/lighter/trading.go index e2ab2749..e148e472 100644 --- a/trader/lighter/trading.go +++ b/trader/lighter/trading.go @@ -394,6 +394,14 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int return nil, err } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet := string(respBody) + if len(snippet) > 512 { + snippet = snippet[:512] + "..." + } + return nil, fmt.Errorf("Lighter sendTx HTTP error (status %d): %s", resp.StatusCode, snippet) + } + // Parse response var sendResp SendTxResponse if err := json.Unmarshal(respBody, &sendResp); err != nil { diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 69b82f7d..e8345a1e 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -52,8 +52,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const savedToken = localStorage.getItem('auth_token') const savedUser = localStorage.getItem('auth_user') if (savedToken && savedUser) { - setToken(savedToken) - setUser(JSON.parse(savedUser)) + try { + setToken(savedToken) + setUser(JSON.parse(savedUser)) + } catch { + // Corrupted localStorage data — clear and force re-login + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + } } setIsLoading(false) @@ -65,8 +71,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const savedUser = localStorage.getItem('auth_user') if (savedToken && savedUser) { - setToken(savedToken) - setUser(JSON.parse(savedUser)) + try { + setToken(savedToken) + setUser(JSON.parse(savedUser)) + } catch { + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + } } setIsLoading(false) }) diff --git a/web/src/lib/api/helpers.ts b/web/src/lib/api/helpers.ts index b1d163c9..27982710 100644 --- a/web/src/lib/api/helpers.ts +++ b/web/src/lib/api/helpers.ts @@ -36,5 +36,9 @@ export async function handleJSONResponse(res: Response): Promise { if (!text) { return {} as T } - return JSON.parse(text) as T + try { + return JSON.parse(text) as T + } catch { + throw new Error(`Invalid JSON response: ${text.slice(0, 200)}`) + } }