From 6911bc3e2baeee7d762ee0496717f7c430af04e3 Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 12:01:44 +0800 Subject: [PATCH] fix: prevent panics from unsafe type assertions in trading code + add request body limit Security & Reliability: - Add requestBodyLimitMiddleware (1MB) to prevent OOM from oversized API payloads - Fix defer resp.Body.Close() inside loop in getPublicIPFromAPI (connection leak) - Add posFloat64/posString safe helpers for position map access Panic Prevention (critical for trading): - Convert 30+ unsafe type assertions (pos["key"].(type)) to safe comma-ok pattern across all exchange traders: OKX, Hyperliquid, Aster, Bybit, KuCoin, Gate, Bitget, Binance - Fix auto_trader_risk.go: drawdown monitor could panic and silently stop monitoring, leaving positions unprotected - Fix auto_trader_decision.go & auto_trader_loop.go: core trading loop position parsing now crash-proof - All trader/ code now has zero unsafe type assertions Frontend: - Fix config.ts: rejected promise cached forever on network error (never retries) --- api/server.go | 42 ++++++++++++++++++++++------- trader/aster/trader_account.go | 6 ++--- trader/aster/trader_orders.go | 4 +-- trader/auto_trader_decision.go | 20 +++++++------- trader/auto_trader_loop.go | 14 +++++----- trader/auto_trader_risk.go | 13 +++++---- trader/binance/futures_orders.go | 2 +- trader/binance/futures_positions.go | 4 +-- trader/bitget/trader_orders.go | 4 +-- trader/bybit/trader_orders.go | 2 +- trader/gate/trader_orders.go | 10 ++++--- trader/helpers.go | 13 +++++++++ trader/hyperliquid/trader_orders.go | 8 +++--- trader/kucoin/trader_orders.go | 4 +-- trader/okx/trader_orders.go | 6 ++--- web/src/lib/config.ts | 5 ++++ 16 files changed, 101 insertions(+), 56 deletions(-) diff --git a/api/server.go b/api/server.go index 4a9a3315..25a950e1 100644 --- a/api/server.go +++ b/api/server.go @@ -36,6 +36,9 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ router := gin.Default() + // Limit request body size to prevent OOM from oversized payloads (1MB default) + router.Use(requestBodyLimitMiddleware(1 << 20)) + // Enable CORS router.Use(corsMiddleware()) @@ -57,6 +60,17 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ return s } +// requestBodyLimitMiddleware limits the size of incoming request bodies. +// Returns 413 Payload Too Large if the body exceeds maxBytes. +func requestBodyLimitMiddleware(maxBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + } + c.Next() + } +} + // corsMiddleware CORS middleware with configurable allowed origins. // Set CORS_ALLOWED_ORIGINS env var to a comma-separated list of origins // (e.g. "http://localhost:5173,https://nofx.example.com"). @@ -445,25 +459,33 @@ func getPublicIPFromAPI() string { } for _, service := range services { - resp, err := client.Get(service) - if err != nil { - continue - } - defer resp.Body.Close() + ip := func() string { + resp, err := client.Get(service) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "" + } - if resp.StatusCode == http.StatusOK { body := make([]byte, 128) n, err := resp.Body.Read(body) if err != nil && err.Error() != "EOF" { - continue + return "" } - ip := strings.TrimSpace(string(body[:n])) - parsedIP := net.ParseIP(ip) + candidate := strings.TrimSpace(string(body[:n])) + parsedIP := net.ParseIP(candidate) // Verify if it's a valid IPv4 address (not containing ":") if parsedIP != nil && parsedIP.To4() != nil { - return ip + return candidate } + return "" + }() + if ip != "" { + return ip } } diff --git a/trader/aster/trader_account.go b/trader/aster/trader_account.go index 52aecbe4..99a99416 100644 --- a/trader/aster/trader_account.go +++ b/trader/aster/trader_account.go @@ -70,12 +70,12 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) { totalMarginUsed := 0.0 realUnrealizedPnl := 0.0 for _, pos := range positions { - markPrice := pos["markPrice"].(float64) - quantity := pos["positionAmt"].(float64) + markPrice, _ := pos["markPrice"].(float64) + quantity, _ := pos["positionAmt"].(float64) if quantity < 0 { quantity = -quantity } - unrealizedPnl := pos["unRealizedProfit"].(float64) + unrealizedPnl, _ := pos["unRealizedProfit"].(float64) realUnrealizedPnl += unrealizedPnl leverage := 10 diff --git a/trader/aster/trader_orders.go b/trader/aster/trader_orders.go index 12ce9502..8e300b85 100644 --- a/trader/aster/trader_orders.go +++ b/trader/aster/trader_orders.go @@ -166,7 +166,7 @@ func (t *AsterTrader) CloseLong(symbol string, quantity float64) (map[string]int for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "long" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } @@ -249,7 +249,7 @@ func (t *AsterTrader) CloseShort(symbol string, quantity float64) (map[string]in for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "short" { // Aster's GetPositions has already converted short position quantity to positive, use directly - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } diff --git a/trader/auto_trader_decision.go b/trader/auto_trader_decision.go index b2898174..c4cf837f 100644 --- a/trader/auto_trader_decision.go +++ b/trader/auto_trader_decision.go @@ -133,12 +133,12 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { totalMarginUsed := 0.0 totalUnrealizedPnLCalculated := 0.0 for _, pos := range positions { - markPrice := pos["markPrice"].(float64) - quantity := pos["positionAmt"].(float64) + markPrice := posFloat64(pos, "markPrice") + quantity := posFloat64(pos, "positionAmt") if quantity < 0 { quantity = -quantity } - unrealizedPnl := pos["unRealizedProfit"].(float64) + unrealizedPnl := posFloat64(pos, "unRealizedProfit") totalUnrealizedPnLCalculated += unrealizedPnl leverage := 10 @@ -199,16 +199,16 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) { var result []map[string]interface{} for _, pos := range positions { - symbol := pos["symbol"].(string) - side := pos["side"].(string) - entryPrice := pos["entryPrice"].(float64) - markPrice := pos["markPrice"].(float64) - quantity := pos["positionAmt"].(float64) + symbol := posString(pos, "symbol") + side := posString(pos, "side") + entryPrice := posFloat64(pos, "entryPrice") + markPrice := posFloat64(pos, "markPrice") + quantity := posFloat64(pos, "positionAmt") if quantity < 0 { quantity = -quantity } - unrealizedPnl := pos["unRealizedProfit"].(float64) - liquidationPrice := pos["liquidationPrice"].(float64) + unrealizedPnl := posFloat64(pos, "unRealizedProfit") + liquidationPrice := posFloat64(pos, "liquidationPrice") leverage := 10 if lev, ok := pos["leverage"].(float64); ok { diff --git a/trader/auto_trader_loop.go b/trader/auto_trader_loop.go index ae440699..cbf96435 100644 --- a/trader/auto_trader_loop.go +++ b/trader/auto_trader_loop.go @@ -331,11 +331,11 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { currentPositionKeys := make(map[string]bool) for _, pos := range positions { - symbol := pos["symbol"].(string) - side := pos["side"].(string) - entryPrice := pos["entryPrice"].(float64) - markPrice := pos["markPrice"].(float64) - quantity := pos["positionAmt"].(float64) + symbol := posString(pos, "symbol") + side := posString(pos, "side") + entryPrice := posFloat64(pos, "entryPrice") + markPrice := posFloat64(pos, "markPrice") + quantity := posFloat64(pos, "positionAmt") if quantity < 0 { quantity = -quantity // Short position quantity is negative, convert to positive } @@ -345,8 +345,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { continue } - unrealizedPnl := pos["unRealizedProfit"].(float64) - liquidationPrice := pos["liquidationPrice"].(float64) + unrealizedPnl := posFloat64(pos, "unRealizedProfit") + liquidationPrice := posFloat64(pos, "liquidationPrice") // Calculate margin used (estimated) leverage := 10 // Default value, should actually be fetched from position info diff --git a/trader/auto_trader_risk.go b/trader/auto_trader_risk.go index f7166070..1ed575c4 100644 --- a/trader/auto_trader_risk.go +++ b/trader/auto_trader_risk.go @@ -41,11 +41,14 @@ func (at *AutoTrader) checkPositionDrawdown() { } for _, pos := range positions { - symbol := pos["symbol"].(string) - side := pos["side"].(string) - entryPrice := pos["entryPrice"].(float64) - markPrice := pos["markPrice"].(float64) - quantity := pos["positionAmt"].(float64) + symbol, _ := pos["symbol"].(string) + side, _ := pos["side"].(string) + entryPrice, _ := pos["entryPrice"].(float64) + markPrice, _ := pos["markPrice"].(float64) + quantity, _ := pos["positionAmt"].(float64) + if symbol == "" || side == "" || entryPrice == 0 { + continue // skip malformed position data + } if quantity < 0 { quantity = -quantity // Short position quantity is negative, convert to positive } diff --git a/trader/binance/futures_orders.go b/trader/binance/futures_orders.go index 3e3f9644..aa2409d6 100644 --- a/trader/binance/futures_orders.go +++ b/trader/binance/futures_orders.go @@ -131,7 +131,7 @@ func (t *FuturesTrader) CloseLong(symbol string, quantity float64) (map[string]i for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "long" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } diff --git a/trader/binance/futures_positions.go b/trader/binance/futures_positions.go index d49e960a..d0986872 100644 --- a/trader/binance/futures_positions.go +++ b/trader/binance/futures_positions.go @@ -227,7 +227,7 @@ func (t *FuturesTrader) GetSymbolPrecision(symbol string) (int, error) { // Get precision from LOT_SIZE filter for _, filter := range s.Filters { if filter["filterType"] == "LOT_SIZE" { - stepSize := filter["stepSize"].(string) + stepSize, _ := filter["stepSize"].(string) precision := calculatePrecision(stepSize) logger.Infof(" %s quantity precision: %d (stepSize: %s)", symbol, precision, stepSize) return precision, nil @@ -264,7 +264,7 @@ func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) { // Get precision from PRICE_FILTER filter for _, filter := range s.Filters { if filter["filterType"] == "PRICE_FILTER" { - tickSize := filter["tickSize"].(string) + tickSize, _ := filter["tickSize"].(string) precision := calculatePrecision(tickSize) return precision, nil } diff --git a/trader/bitget/trader_orders.go b/trader/bitget/trader_orders.go index f08a7a38..16af3a44 100644 --- a/trader/bitget/trader_orders.go +++ b/trader/bitget/trader_orders.go @@ -129,7 +129,7 @@ func (t *BitgetTrader) CloseLong(symbol string, quantity float64) (map[string]in } for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "long" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } @@ -192,7 +192,7 @@ func (t *BitgetTrader) CloseShort(symbol string, quantity float64) (map[string]i } for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "short" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } diff --git a/trader/bybit/trader_orders.go b/trader/bybit/trader_orders.go index 73ed87a3..ab62ee45 100644 --- a/trader/bybit/trader_orders.go +++ b/trader/bybit/trader_orders.go @@ -109,7 +109,7 @@ func (t *BybitTrader) CloseLong(symbol string, quantity float64) (map[string]int for _, pos := range positions { side, _ := pos["side"].(string) if pos["symbol"] == symbol && strings.ToLower(side) == "long" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } diff --git a/trader/gate/trader_orders.go b/trader/gate/trader_orders.go index 198a6acb..ea43587b 100644 --- a/trader/gate/trader_orders.go +++ b/trader/gate/trader_orders.go @@ -165,9 +165,10 @@ func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]inte return nil, err } for _, pos := range positions { - posSymbol := t.convertSymbol(pos["symbol"].(string)) + rawSymbol, _ := pos["symbol"].(string) + posSymbol := t.convertSymbol(rawSymbol) if posSymbol == symbol && pos["side"] == "long" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } @@ -233,9 +234,10 @@ func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]int return nil, err } for _, pos := range positions { - posSymbol := t.convertSymbol(pos["symbol"].(string)) + rawSymbol, _ := pos["symbol"].(string) + posSymbol := t.convertSymbol(rawSymbol) if posSymbol == symbol && pos["side"] == "short" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } diff --git a/trader/helpers.go b/trader/helpers.go index 47f69fa6..6819ae32 100644 --- a/trader/helpers.go +++ b/trader/helpers.go @@ -5,6 +5,19 @@ import ( "strconv" ) +// posFloat64 extracts a float64 from a position map, returning 0 on failure. +// Use in loops where a malformed position should be skipped, not crash. +func posFloat64(pos map[string]interface{}, key string) float64 { + v, _ := SafeFloat64(pos, key) + return v +} + +// posString extracts a string from a position map, returning "" on failure. +func posString(pos map[string]interface{}, key string) string { + v, _ := SafeString(pos, key) + return v +} + // SafeFloat64 Safely extract float64 value from map func SafeFloat64(data map[string]interface{}, key string) (float64, error) { value, ok := data[key] diff --git a/trader/hyperliquid/trader_orders.go b/trader/hyperliquid/trader_orders.go index 4792c0b4..cc327b1f 100644 --- a/trader/hyperliquid/trader_orders.go +++ b/trader/hyperliquid/trader_orders.go @@ -179,9 +179,9 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri } for _, pos := range positions { - posSymbol := pos["symbol"].(string) + posSymbol, _ := pos["symbol"].(string) if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "long" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } @@ -266,9 +266,9 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str } for _, pos := range positions { - posSymbol := pos["symbol"].(string) + posSymbol, _ := pos["symbol"].(string) if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "short" { - quantity = pos["positionAmt"].(float64) + quantity, _ = pos["positionAmt"].(float64) break } } diff --git a/trader/kucoin/trader_orders.go b/trader/kucoin/trader_orders.go index 41819202..00687003 100644 --- a/trader/kucoin/trader_orders.go +++ b/trader/kucoin/trader_orders.go @@ -161,7 +161,7 @@ func (t *KuCoinTrader) CloseLong(symbol string, quantity float64) (map[string]in var marginMode string = "CROSS" // Default to CROSS for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "long" { - actualQty = pos["positionAmt"].(float64) + actualQty, _ = pos["positionAmt"].(float64) posFound = true // Get margin mode from position if mgnMode, ok := pos["mgnMode"].(string); ok { @@ -242,7 +242,7 @@ func (t *KuCoinTrader) CloseShort(symbol string, quantity float64) (map[string]i var marginMode string = "CROSS" // Default to CROSS for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "short" { - actualQty = pos["positionAmt"].(float64) + actualQty, _ = pos["positionAmt"].(float64) posFound = true // Get margin mode from position if mgnMode, ok := pos["mgnMode"].(string); ok { diff --git a/trader/okx/trader_orders.go b/trader/okx/trader_orders.go index 33697acc..65ccd60d 100644 --- a/trader/okx/trader_orders.go +++ b/trader/okx/trader_orders.go @@ -188,11 +188,11 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter for _, pos := range positions { logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v", pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"]) if pos["symbol"] == symbol { - side := pos["side"].(string) + side, _ := pos["side"].(string) // In net_mode, "long" means positive position // In dual mode, check explicit "long" side if side == "long" || (t.positionMode == "net_mode" && side == "long") { - actualQty = pos["positionAmt"].(float64) + actualQty, _ = pos["positionAmt"].(float64) posFound = true if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" { posMgnMode = mgnMode @@ -300,7 +300,7 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v", pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"]) if pos["symbol"] == symbol && pos["side"] == "short" { - actualQty = pos["positionAmt"].(float64) + actualQty, _ = pos["positionAmt"].(float64) posFound = true if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" { posMgnMode = mgnMode diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 54d138e0..5c240a57 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -19,6 +19,11 @@ export function getSystemConfig(): Promise { cachedConfig = data return data }) + .catch((err) => { + // Reset so next call retries instead of returning the rejected promise forever + configPromise = null + throw err + }) return configPromise }