mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
fix(security): remove decrypt oracle, redact secret logs, harden auth, bump Go
Address multiple vulnerabilities found during security review: - Remove unauthenticated POST /api/crypto/decrypt decryption oracle (route, handler, dead frontend helper) + regression test. Transport encryption is one-directional; the server never needs to decrypt arbitrary client payloads. - Redact secrets in config-update logs: handler_ai_model/handler_exchange logged %+v of decrypted requests, leaking API keys / secret keys / passphrases / private keys. Use named types shared with the log sanitizer so the masking can never drift again; extend masking to passphrase + lighter_api_key_private_key. - crypto: require a valid timestamp in DecryptPayload (a missing ts previously skipped replay protection entirely). - crypto: EncryptedString.Value() now fails closed instead of silently persisting plaintext secrets when encryption errors. - auth: per-IP token-bucket rate limiting on /login and /register against online brute-force; raise registration password minimum 6 -> 8; add dummy bcrypt compare on unknown-email login to close the user-enumeration timing channel. - IDOR: getTraderFromQuery no longer falls back to the global in-memory trader map; trader access is strictly scoped to the authenticated caller. - Bump Go 1.25.10 -> 1.25.11 to resolve reachable net/textproto and crypto/x509 stdlib advisories (govulncheck now reports 0 affecting vulnerabilities).
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
@@ -53,28 +52,16 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Encrypted Data Decryption Endpoint ====================
|
||||
|
||||
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
|
||||
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
|
||||
var payload crypto.EncryptedPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
|
||||
if err != nil {
|
||||
log.Printf("❌ Decryption failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, map[string]string{
|
||||
"plaintext": decrypted,
|
||||
})
|
||||
}
|
||||
// ==================== Encrypted Data Decryption ====================
|
||||
//
|
||||
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
|
||||
// encryption is one-directional — clients encrypt sensitive fields to the
|
||||
// server's RSA public key and the authenticated config-update handlers
|
||||
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
|
||||
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
|
||||
// generic decrypt route would turn the server into a decryption oracle that any
|
||||
// unauthenticated caller could use to recover the plaintext of a captured
|
||||
// ciphertext, defeating the entire transport-encryption layer.
|
||||
|
||||
// ==================== Audit Log Query Endpoint ====================
|
||||
|
||||
|
||||
@@ -38,13 +38,19 @@ type SafeModelConfig struct {
|
||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||
}
|
||||
|
||||
// ModelConfigUpdate is a single model's update payload. It is a named type
|
||||
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
|
||||
// guaranteed to stay in sync with this shape — a mismatch there is what let
|
||||
// plaintext credentials reach the logs previously.
|
||||
type ModelConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
Models map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
} `json:"models"`
|
||||
Models map[string]ModelConfigUpdate `json:"models"`
|
||||
}
|
||||
|
||||
// handleGetModelConfigs Get AI model configurations
|
||||
@@ -225,7 +231,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
// Don't return error here since model config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ AI model config updated: %+v", req.Models)
|
||||
logger.Infof("✓ AI model config updated: %+v", SanitizeModelConfigForLog(req.Models))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
|
||||
}
|
||||
|
||||
|
||||
@@ -69,24 +69,30 @@ func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
|
||||
// named type (rather than an inline anonymous struct) so the log-sanitizer in
|
||||
// utils.go is guaranteed to cover every sensitive field — a drift between the
|
||||
// two shapes is what let passphrases / private keys reach the logs previously.
|
||||
type ExchangeConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
} `json:"exchanges"`
|
||||
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
|
||||
}
|
||||
|
||||
// CreateExchangeRequest request structure for creating a new exchange account
|
||||
@@ -297,7 +303,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// Don't return error here since exchange config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
|
||||
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
@@ -129,6 +129,13 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
|
||||
// against when the submitted email does not exist so that login takes roughly
|
||||
// the same time whether or not the account exists — closing the timing side
|
||||
// channel that would otherwise let an attacker enumerate valid emails (a fast
|
||||
// "no such user" vs. a slow bcrypt compare). It is not a secret.
|
||||
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
|
||||
|
||||
// handleLogin Handle user login request
|
||||
func (s *Server) handleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -144,6 +151,9 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
// Perform a dummy comparison so the response time does not reveal
|
||||
// whether the email exists (anti user-enumeration), then fail uniformly.
|
||||
auth.CheckPassword(req.Password, dummyPasswordHash)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
101
api/ratelimit.go
Normal file
101
api/ratelimit.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
|
||||
// client IP. It is used to throttle the unauthenticated auth endpoints
|
||||
// (login / register) against online brute-force attacks.
|
||||
//
|
||||
// Design notes:
|
||||
// - Per-IP token bucket with lazy refill (no background goroutine).
|
||||
// - Idle buckets are evicted opportunistically so a flood of distinct source
|
||||
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
|
||||
// - This is a throttle, not an authenticator. Behind a reverse proxy the
|
||||
// effective key is whatever gin's ClientIP() resolves; operators who
|
||||
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
|
||||
// reflects the real peer rather than a spoofable header.
|
||||
type ipRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
rate float64 // tokens added per second
|
||||
burst float64 // maximum tokens (and initial fill)
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
|
||||
// and then refills at `ratePerSec` tokens/second per client IP.
|
||||
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
|
||||
return &ipRateLimiter{
|
||||
buckets: make(map[string]*rlBucket),
|
||||
rate: ratePerSec,
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
// allow reports whether a request from key is permitted at time now, consuming
|
||||
// one token when it is.
|
||||
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
|
||||
// under a spoofed-IP flood without needing a background goroutine.
|
||||
if l.lastGC.IsZero() {
|
||||
l.lastGC = now
|
||||
}
|
||||
if now.Sub(l.lastGC) > time.Minute {
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > 10*time.Minute {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &rlBucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
|
||||
// Refill based on elapsed time, capped at burst.
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
if elapsed > 0 {
|
||||
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
|
||||
b.last = now
|
||||
}
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
|
||||
// caller exceeds the configured rate.
|
||||
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !l.allow(c.ClientIP(), time.Now()) {
|
||||
c.Header("Retry-After", "60")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Too many requests. Please slow down and try again in a minute.",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
54
api/ratelimit_test.go
Normal file
54
api/ratelimit_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
|
||||
// immediate attempts and is then throttled until tokens refill.
|
||||
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
|
||||
// 1 token/sec, burst of 3.
|
||||
l := newIPRateLimiter(1.0, 3)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// First 3 requests in the same instant are allowed (the burst).
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request %d in burst should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
// 4th in the same instant is throttled.
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request beyond burst should be throttled")
|
||||
}
|
||||
|
||||
// After 1 second, one token refills → exactly one more request allowed.
|
||||
now = now.Add(time.Second)
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("one token should have refilled after 1s")
|
||||
}
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("only one token should refill per second")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
|
||||
// not throttle a different IP.
|
||||
func TestIPRateLimiterIsolatesClients(t *testing.T) {
|
||||
l := newIPRateLimiter(1.0, 2)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// Exhaust IP A.
|
||||
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A burst should be allowed")
|
||||
}
|
||||
if l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A should be throttled after burst")
|
||||
}
|
||||
|
||||
// IP B is unaffected.
|
||||
if !l.allow("10.0.0.2", now) {
|
||||
t.Fatalf("IP B should be allowed regardless of IP A")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type Server struct {
|
||||
httpServer *http.Server
|
||||
port int
|
||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||
authLimiter *ipRateLimiter // per-IP throttle for login/register
|
||||
}
|
||||
|
||||
// NewServer Creates API server
|
||||
@@ -49,6 +50,10 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
||||
cryptoHandler: cryptoHandler,
|
||||
exchangeAccountStateCache: NewExchangeAccountStateCache(),
|
||||
port: port,
|
||||
// Auth throttle: allow a small burst (typos / page reloads) then ~1
|
||||
// attempt every 6s (10/min) sustained per IP. Generous for a human,
|
||||
// hostile to online password brute-force.
|
||||
authLimiter: newIPRateLimiter(1.0/6.0, 8),
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
@@ -119,6 +124,12 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
|
||||
// setupRoutes Setup routes
|
||||
func (s *Server) setupRoutes() {
|
||||
// Ensure the auth throttle exists even when the Server was constructed
|
||||
// directly (e.g. in tests) rather than via NewServer.
|
||||
if s.authLimiter == nil {
|
||||
s.authLimiter = newIPRateLimiter(1.0/6.0, 8)
|
||||
}
|
||||
|
||||
// API route group
|
||||
api := s.router.Group("/api")
|
||||
{
|
||||
@@ -141,10 +152,16 @@ func (s *Server) setupRoutes() {
|
||||
s.route(api, "GET", "/hyperliquid/account", "Get Hyperliquid account balance summary", s.handleHyperliquidAccount)
|
||||
s.route(api, "POST", "/hyperliquid/submit-exchange", "Submit a user-signed Hyperliquid approval action", s.handleHyperliquidSubmitExchange)
|
||||
|
||||
// Crypto related endpoints (no authentication required, not exposed to bot)
|
||||
// Crypto related endpoints (no authentication required, not exposed to bot).
|
||||
// SECURITY: only the config + public-key endpoints are exposed. Transport
|
||||
// encryption is one-directional (client encrypts to the server's public key;
|
||||
// the server decrypts internally on the authenticated config-update handlers).
|
||||
// A public POST /crypto/decrypt would be a decryption oracle: any
|
||||
// unauthenticated caller could replay a captured ciphertext and get the
|
||||
// plaintext (exchange/API credentials) back. It is intentionally NOT
|
||||
// registered. See crypto_handler.go.
|
||||
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
||||
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
|
||||
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
|
||||
|
||||
// Public competition data (no authentication required)
|
||||
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
|
||||
@@ -162,9 +179,13 @@ func (s *Server) setupRoutes() {
|
||||
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
||||
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
|
||||
|
||||
// Authentication related routes (no authentication required)
|
||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
||||
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||
// Authentication related routes (no authentication required).
|
||||
// These are throttled per-IP to blunt online password brute-force; see
|
||||
// ratelimit.go. Everything else in the public block is read-only or
|
||||
// idempotent, so the throttle is scoped to the credential endpoints.
|
||||
authRoutes := api.Group("/", rateLimitMiddleware(s.authLimiter))
|
||||
s.route(authRoutes, "POST", "/register", "Register new user", s.handleRegister)
|
||||
s.route(authRoutes, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||
// SECURITY: password/account recovery is NOT exposed over HTTP. An
|
||||
// unauthenticated recovery endpoint is a remote auth-bypass on any
|
||||
// public-facing deployment (the confirm phrase is in the frontend and
|
||||
@@ -564,13 +585,15 @@ func isPrivateIP(ip net.IP) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// getTraderFromQuery resolves a trader from the ?trader_id= query parameter.
|
||||
// getTraderFromQuery resolves a trader from the ?trader_id= query parameter,
|
||||
// strictly scoped to the authenticated caller.
|
||||
//
|
||||
// This project is single-user by design, so a strict cross-tenant ownership
|
||||
// check would be theatre. We still perform a soft check (the requested trader
|
||||
// must appear in the caller's store list when present) — this is cheap defense
|
||||
// in depth that future-proofs against accidental multi-account drift and
|
||||
// catches typos that would otherwise return another account's data.
|
||||
// Ownership is always enforced against the caller's own trader list in the
|
||||
// store. We deliberately never fall back to the global in-memory trader map
|
||||
// (TraderManager holds every account's traders): returning an entry from it for
|
||||
// a trader the caller does not own is a cross-tenant data leak (IDOR) — a
|
||||
// freshly-registered user with no traders of their own could otherwise pass any
|
||||
// other account's trader_id and read its balance, positions and AI decisions.
|
||||
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Query("trader_id")
|
||||
@@ -580,33 +603,27 @@ func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, str
|
||||
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
// Resolve strictly from the caller's own trader list.
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load traders for this account: %w", err)
|
||||
}
|
||||
if len(userTraders) == 0 {
|
||||
return nil, "", fmt.Errorf("No available traders")
|
||||
}
|
||||
|
||||
if traderID == "" {
|
||||
// No trader_id specified — return first trader for this user, falling
|
||||
// back to the first in-memory trader if no per-user list exists yet.
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err == nil && len(userTraders) > 0 {
|
||||
return s.traderManager, userTraders[0].ID, nil
|
||||
}
|
||||
ids := s.traderManager.GetTraderIDs()
|
||||
if len(ids) == 0 {
|
||||
return nil, "", fmt.Errorf("No available traders")
|
||||
}
|
||||
return s.traderManager, ids[0], nil
|
||||
// No trader_id specified — default to the caller's first trader.
|
||||
return s.traderManager, userTraders[0].ID, nil
|
||||
}
|
||||
|
||||
// Soft ownership check: if the caller owns any traders in the store and
|
||||
// the requested ID is NOT among them, treat as not-found instead of
|
||||
// silently returning whatever happens to be in the global in-memory map.
|
||||
if userTraders, err := s.store.Trader().List(userID); err == nil && len(userTraders) > 0 {
|
||||
for _, t := range userTraders {
|
||||
if t.ID == traderID {
|
||||
return s.traderManager, traderID, nil
|
||||
}
|
||||
// A trader_id was supplied — it must belong to the caller.
|
||||
for _, t := range userTraders {
|
||||
if t.ID == traderID {
|
||||
return s.traderManager, traderID, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("trader not found for this account")
|
||||
}
|
||||
|
||||
return s.traderManager, traderID, nil
|
||||
return nil, "", fmt.Errorf("trader not found for this account")
|
||||
}
|
||||
|
||||
// authMiddleware JWT authentication middleware
|
||||
|
||||
@@ -2,11 +2,38 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestPublicDecryptRouteNotRegistered is a security regression test: the
|
||||
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
|
||||
// must never be re-registered. A built server's router must not route to it.
|
||||
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
s := &Server{router: gin.New()}
|
||||
s.setupRoutes()
|
||||
|
||||
for _, r := range s.router.Routes() {
|
||||
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
|
||||
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
|
||||
}
|
||||
}
|
||||
|
||||
// Also assert at the HTTP layer that the route is not handled.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
|
||||
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
36
api/utils.go
36
api/utils.go
@@ -15,13 +15,10 @@ func MaskSensitiveString(s string) string {
|
||||
return s[:4] + "****" + s[length-4:]
|
||||
}
|
||||
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output
|
||||
func SanitizeModelConfigForLog(models map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}) map[string]interface{} {
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output.
|
||||
// Takes the same ModelConfigUpdate type used by the request handler so the two
|
||||
// can never drift out of sync.
|
||||
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for modelID, cfg := range models {
|
||||
safe[modelID] = map[string]interface{}{
|
||||
@@ -34,19 +31,12 @@ func SanitizeModelConfigForLog(models map[string]struct {
|
||||
return safe
|
||||
}
|
||||
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
|
||||
func SanitizeExchangeConfigForLog(exchanges map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
}) map[string]interface{} {
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
|
||||
// Takes the same ExchangeConfigUpdate type used by the request handler so every
|
||||
// sensitive field is guaranteed to be masked — adding a field to the request
|
||||
// type without masking it here would not compile around this helper, but more
|
||||
// importantly keeps the masking exhaustive.
|
||||
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for exchangeID, cfg := range exchanges {
|
||||
safeExchange := map[string]interface{}{
|
||||
@@ -61,12 +51,18 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct {
|
||||
if cfg.SecretKey != "" {
|
||||
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
|
||||
}
|
||||
if cfg.Passphrase != "" {
|
||||
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
|
||||
}
|
||||
if cfg.AsterPrivateKey != "" {
|
||||
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
|
||||
}
|
||||
if cfg.LighterPrivateKey != "" {
|
||||
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
|
||||
}
|
||||
if cfg.LighterAPIKeyPrivateKey != "" {
|
||||
safeExchange["lighter_api_key_private_key"] = MaskSensitiveString(cfg.LighterAPIKeyPrivateKey)
|
||||
}
|
||||
|
||||
// Add non-sensitive fields directly
|
||||
if cfg.HyperliquidWalletAddr != "" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -48,12 +50,7 @@ func TestMaskSensitiveString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeModelConfigForLog(t *testing.T) {
|
||||
models := map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}{
|
||||
models := map[string]ModelConfigUpdate{
|
||||
"deepseek": {
|
||||
Enabled: true,
|
||||
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
@@ -88,32 +85,29 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
exchanges := map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
}{
|
||||
exchanges := map[string]ExchangeConfigUpdate{
|
||||
"binance": {
|
||||
Enabled: true,
|
||||
APIKey: "binance_api_key_1234567890abcdef",
|
||||
SecretKey: "binance_secret_key_1234567890abcdef",
|
||||
Testnet: false,
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
"okx": {
|
||||
Enabled: true,
|
||||
APIKey: "okx_api_key_1234567890abcdef",
|
||||
SecretKey: "okx_secret_key_1234567890abcdef",
|
||||
Passphrase: "okx_passphrase_supersecret_value",
|
||||
},
|
||||
"lighter": {
|
||||
Enabled: true,
|
||||
LighterWalletAddr: "0xabcdef0000000000000000000000000000000000",
|
||||
LighterPrivateKey: "lighter_private_key_1234567890abcdef",
|
||||
LighterAPIKeyPrivateKey: "lighter_api_key_private_key_1234567890abcdef",
|
||||
},
|
||||
"hyperliquid": {
|
||||
Enabled: true,
|
||||
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
Testnet: false,
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -143,6 +137,32 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
|
||||
}
|
||||
|
||||
// Check OKX passphrase is masked (regression: previously not covered)
|
||||
okxConfig, ok := result["okx"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("okx config not found or wrong type")
|
||||
}
|
||||
maskedPassphrase, ok := okxConfig["passphrase"].(string)
|
||||
if !ok {
|
||||
t.Fatal("okx passphrase not found or wrong type")
|
||||
}
|
||||
if maskedPassphrase != "okx_****alue" {
|
||||
t.Errorf("expected masked passphrase='okx_****alue', got %q", maskedPassphrase)
|
||||
}
|
||||
|
||||
// Check Lighter API key private key is masked (regression: previously not covered)
|
||||
lighterConfig, ok := result["lighter"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("lighter config not found or wrong type")
|
||||
}
|
||||
maskedLighterAPIKey, ok := lighterConfig["lighter_api_key_private_key"].(string)
|
||||
if !ok {
|
||||
t.Fatal("lighter_api_key_private_key not found or wrong type")
|
||||
}
|
||||
if maskedLighterAPIKey != "ligh****cdef" {
|
||||
t.Errorf("expected masked lighter_api_key_private_key='ligh****cdef', got %q", maskedLighterAPIKey)
|
||||
}
|
||||
|
||||
// Check Hyperliquid configuration
|
||||
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -160,6 +180,41 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeExchangeConfigForLog_NoPlaintextSecrets renders the sanitized log
|
||||
// output exactly as the handler does (`%+v`) and asserts that no plaintext
|
||||
// secret — including the passphrase and lighter API key private key that were
|
||||
// historically not redacted — survives into the log line.
|
||||
func TestSanitizeExchangeConfigForLog_NoPlaintextSecrets(t *testing.T) {
|
||||
secrets := map[string]string{
|
||||
"api_key": "binance_api_key_1234567890abcdef",
|
||||
"secret_key": "binance_secret_key_1234567890abcdef",
|
||||
"passphrase": "okx_passphrase_supersecret_value",
|
||||
"aster_private_key": "aster_private_key_1234567890abcdef",
|
||||
"lighter_private_key": "lighter_private_key_1234567890abcdef",
|
||||
"lighter_api_key_private_key": "lighter_api_key_private_key_1234567890abcdef",
|
||||
}
|
||||
|
||||
exchanges := map[string]ExchangeConfigUpdate{
|
||||
"okx": {
|
||||
Enabled: true,
|
||||
APIKey: secrets["api_key"],
|
||||
SecretKey: secrets["secret_key"],
|
||||
Passphrase: secrets["passphrase"],
|
||||
AsterPrivateKey: secrets["aster_private_key"],
|
||||
LighterPrivateKey: secrets["lighter_private_key"],
|
||||
LighterAPIKeyPrivateKey: secrets["lighter_api_key_private_key"],
|
||||
},
|
||||
}
|
||||
|
||||
rendered := fmt.Sprintf("%+v", SanitizeExchangeConfigForLog(exchanges))
|
||||
|
||||
for field, secret := range secrets {
|
||||
if strings.Contains(rendered, secret) {
|
||||
t.Errorf("sanitized log leaked plaintext %s: %q present in %q", field, secret, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -282,12 +282,16 @@ func isEncryptedStorageValue(value string) bool {
|
||||
}
|
||||
|
||||
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
||||
// 1. Validate timestamp (prevent replay attacks)
|
||||
if payload.TS != 0 {
|
||||
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||
return nil, errors.New("timestamp invalid or expired")
|
||||
}
|
||||
// 1. Validate timestamp (prevent replay attacks).
|
||||
// The timestamp is mandatory: a missing/zero ts previously skipped this check
|
||||
// entirely, which let a captured ciphertext be replayed indefinitely. The
|
||||
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
|
||||
if payload.TS == 0 {
|
||||
return nil, errors.New("missing timestamp")
|
||||
}
|
||||
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||
return nil, errors.New("timestamp invalid or expired")
|
||||
}
|
||||
|
||||
// 2. Decode base64url
|
||||
@@ -455,8 +459,11 @@ func (es EncryptedString) Value() (driver.Value, error) {
|
||||
if globalCryptoService != nil {
|
||||
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
|
||||
if err != nil {
|
||||
// If encryption fails, return the original value
|
||||
return string(es), nil
|
||||
// Fail closed: never silently persist a plaintext secret when
|
||||
// encryption was expected to happen. Returning the error aborts the
|
||||
// write so a misconfigured/broken crypto service cannot leak
|
||||
// credentials into the database in cleartext.
|
||||
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,6 +1,6 @@
|
||||
module nofx
|
||||
|
||||
go 1.25.10
|
||||
go 1.25.11
|
||||
|
||||
require (
|
||||
github.com/adshao/go-binance/v2 v2.8.9
|
||||
@@ -22,6 +22,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
@@ -96,7 +97,6 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
|
||||
@@ -179,24 +179,12 @@ export class CryptoService {
|
||||
return data.public_key || ''
|
||||
}
|
||||
|
||||
static async decryptSensitiveData(
|
||||
payload: EncryptedPayload
|
||||
): Promise<string> {
|
||||
const response = await fetch('/api/crypto/decrypt', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Decryption failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.plaintext
|
||||
}
|
||||
// NOTE: there is intentionally no decryptSensitiveData() here. Transport
|
||||
// encryption is one-directional: the client encrypts sensitive fields to the
|
||||
// server's public key and the server decrypts them internally on the
|
||||
// authenticated config endpoints. The server exposes no public decrypt route,
|
||||
// so a client-side decrypt helper would be both useless and a security
|
||||
// anti-pattern (it implied an unauthenticated decryption oracle existed).
|
||||
}
|
||||
|
||||
// 生成混淆字符串(用于剪贴板混淆)
|
||||
|
||||
Reference in New Issue
Block a user