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:
tinkle-community
2026-06-05 22:08:26 +08:00
parent 577a0918c3
commit 1aea7abc38
13 changed files with 406 additions and 152 deletions

View File

@@ -1,7 +1,6 @@
package api package api
import ( import (
"log"
"net/http" "net/http"
"nofx/config" "nofx/config"
"nofx/crypto" "nofx/crypto"
@@ -53,28 +52,16 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
}) })
} }
// ==================== Encrypted Data Decryption Endpoint ==================== // ==================== Encrypted Data Decryption ====================
//
// HandleDecryptSensitiveData Decrypt encrypted data sent from client // SECURITY: there is deliberately NO public decrypt endpoint. Transport
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) { // encryption is one-directional — clients encrypt sensitive fields to the
var payload crypto.EncryptedPayload // server's RSA public key and the authenticated config-update handlers
if err := c.ShouldBindJSON(&payload); err != nil { // (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) // decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
return // 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.
// 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,
})
}
// ==================== Audit Log Query Endpoint ==================== // ==================== Audit Log Query Endpoint ====================

View File

@@ -38,13 +38,19 @@ type SafeModelConfig struct {
BalanceUSDC string `json:"balanceUsdc,omitempty"` 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 { type UpdateModelConfigRequest struct {
Models map[string]struct { Models map[string]ModelConfigUpdate `json:"models"`
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
} `json:"models"`
} }
// handleGetModelConfigs Get AI model configurations // 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 // 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"}) c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
} }

View File

@@ -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 { type UpdateExchangeConfigRequest struct {
Exchanges map[string]struct { Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
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"`
} }
// CreateExchangeRequest request structure for creating a new exchange account // 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 // 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"}) c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
} }

View File

@@ -60,7 +60,7 @@ func (s *Server) handleRegister(c *gin.Context) {
var req struct { var req struct {
Email string `json:"email" binding:"required,email"` 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"` 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 // handleLogin Handle user login request
func (s *Server) handleLogin(c *gin.Context) { func (s *Server) handleLogin(c *gin.Context) {
var req struct { var req struct {
@@ -144,6 +151,9 @@ func (s *Server) handleLogin(c *gin.Context) {
// Get user information // Get user information
user, err := s.store.User().GetByEmail(req.Email) user, err := s.store.User().GetByEmail(req.Email)
if err != nil { 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"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return return
} }

101
api/ratelimit.go Normal file
View 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
View 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")
}
}

View File

@@ -27,6 +27,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
authLimiter *ipRateLimiter // per-IP throttle for login/register
} }
// NewServer Creates API server // NewServer Creates API server
@@ -49,6 +50,10 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
cryptoHandler: cryptoHandler, cryptoHandler: cryptoHandler,
exchangeAccountStateCache: NewExchangeAccountStateCache(), exchangeAccountStateCache: NewExchangeAccountStateCache(),
port: port, 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 // Setup routes
@@ -119,6 +124,12 @@ func corsMiddleware() gin.HandlerFunc {
// setupRoutes Setup routes // setupRoutes Setup routes
func (s *Server) setupRoutes() { 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 route group
api := s.router.Group("/api") 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, "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) 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/config", s.cryptoHandler.HandleGetCryptoConfig)
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey) api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
// Public competition data (no authentication required) // Public competition data (no authentication required)
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList) 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, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens) s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
// Authentication related routes (no authentication required) // Authentication related routes (no authentication required).
s.route(api, "POST", "/register", "Register new user", s.handleRegister) // These are throttled per-IP to blunt online password brute-force; see
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) // 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 // SECURITY: password/account recovery is NOT exposed over HTTP. An
// unauthenticated recovery endpoint is a remote auth-bypass on any // unauthenticated recovery endpoint is a remote auth-bypass on any
// public-facing deployment (the confirm phrase is in the frontend and // public-facing deployment (the confirm phrase is in the frontend and
@@ -564,13 +585,15 @@ func isPrivateIP(ip net.IP) bool {
return false 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 // Ownership is always enforced against the caller's own trader list in the
// check would be theatre. We still perform a soft check (the requested trader // store. We deliberately never fall back to the global in-memory trader map
// must appear in the caller's store list when present) — this is cheap defense // (TraderManager holds every account's traders): returning an entry from it for
// in depth that future-proofs against accidental multi-account drift and // a trader the caller does not own is a cross-tenant data leak (IDOR) — a
// catches typos that would otherwise return another account's data. // 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) { func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
userID := c.GetString("user_id") userID := c.GetString("user_id")
traderID := c.Query("trader_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) 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 == "" { if traderID == "" {
// No trader_id specified — return first trader for this user, falling // No trader_id specified — default to the caller's first trader.
// back to the first in-memory trader if no per-user list exists yet. return s.traderManager, userTraders[0].ID, nil
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
} }
// Soft ownership check: if the caller owns any traders in the store and // A trader_id was supplied — it must belong to the caller.
// the requested ID is NOT among them, treat as not-found instead of for _, t := range userTraders {
// silently returning whatever happens to be in the global in-memory map. if t.ID == traderID {
if userTraders, err := s.store.Trader().List(userID); err == nil && len(userTraders) > 0 { return s.traderManager, traderID, nil
for _, t := range userTraders {
if t.ID == traderID {
return s.traderManager, traderID, nil
}
} }
return nil, "", fmt.Errorf("trader not found for this account")
} }
return nil, "", fmt.Errorf("trader not found for this account")
return s.traderManager, traderID, nil
} }
// authMiddleware JWT authentication middleware // authMiddleware JWT authentication middleware

View File

@@ -2,11 +2,38 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http"
"net/http/httptest"
"testing" "testing"
"nofx/store" "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 // TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) { func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
tests := []struct { tests := []struct {

View File

@@ -15,13 +15,10 @@ func MaskSensitiveString(s string) string {
return s[:4] + "****" + s[length-4:] return s[:4] + "****" + s[length-4:]
} }
// SanitizeModelConfigForLog Sanitize model configuration for log output // SanitizeModelConfigForLog Sanitize model configuration for log output.
func SanitizeModelConfigForLog(models map[string]struct { // Takes the same ModelConfigUpdate type used by the request handler so the two
Enabled bool `json:"enabled"` // can never drift out of sync.
APIKey string `json:"api_key"` func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}) map[string]interface{} {
safe := make(map[string]interface{}) safe := make(map[string]interface{})
for modelID, cfg := range models { for modelID, cfg := range models {
safe[modelID] = map[string]interface{}{ safe[modelID] = map[string]interface{}{
@@ -34,19 +31,12 @@ func SanitizeModelConfigForLog(models map[string]struct {
return safe return safe
} }
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output // SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
func SanitizeExchangeConfigForLog(exchanges map[string]struct { // Takes the same ExchangeConfigUpdate type used by the request handler so every
Enabled bool `json:"enabled"` // sensitive field is guaranteed to be masked — adding a field to the request
APIKey string `json:"api_key"` // type without masking it here would not compile around this helper, but more
SecretKey string `json:"secret_key"` // importantly keeps the masking exhaustive.
Testnet bool `json:"testnet"` func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
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{} {
safe := make(map[string]interface{}) safe := make(map[string]interface{})
for exchangeID, cfg := range exchanges { for exchangeID, cfg := range exchanges {
safeExchange := map[string]interface{}{ safeExchange := map[string]interface{}{
@@ -61,12 +51,18 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct {
if cfg.SecretKey != "" { if cfg.SecretKey != "" {
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey) safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
} }
if cfg.Passphrase != "" {
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
}
if cfg.AsterPrivateKey != "" { if cfg.AsterPrivateKey != "" {
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey) safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
} }
if cfg.LighterPrivateKey != "" { if cfg.LighterPrivateKey != "" {
safeExchange["lighter_private_key"] = MaskSensitiveString(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 // Add non-sensitive fields directly
if cfg.HyperliquidWalletAddr != "" { if cfg.HyperliquidWalletAddr != "" {

View File

@@ -1,6 +1,8 @@
package api package api
import ( import (
"fmt"
"strings"
"testing" "testing"
) )
@@ -48,12 +50,7 @@ func TestMaskSensitiveString(t *testing.T) {
} }
func TestSanitizeModelConfigForLog(t *testing.T) { func TestSanitizeModelConfigForLog(t *testing.T) {
models := map[string]struct { models := map[string]ModelConfigUpdate{
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}{
"deepseek": { "deepseek": {
Enabled: true, Enabled: true,
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz", APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
@@ -88,32 +85,29 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
} }
func TestSanitizeExchangeConfigForLog(t *testing.T) { func TestSanitizeExchangeConfigForLog(t *testing.T) {
exchanges := map[string]struct { exchanges := map[string]ExchangeConfigUpdate{
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"`
}{
"binance": { "binance": {
Enabled: true, Enabled: true,
APIKey: "binance_api_key_1234567890abcdef", APIKey: "binance_api_key_1234567890abcdef",
SecretKey: "binance_secret_key_1234567890abcdef", SecretKey: "binance_secret_key_1234567890abcdef",
Testnet: false, 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": { "hyperliquid": {
Enabled: true, Enabled: true,
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678", HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
Testnet: false, Testnet: false,
LighterWalletAddr: "",
LighterPrivateKey: "",
}, },
} }
@@ -143,6 +137,32 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey) 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 // Check Hyperliquid configuration
hlConfig, ok := result["hyperliquid"].(map[string]interface{}) hlConfig, ok := result["hyperliquid"].(map[string]interface{})
if !ok { 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) { func TestMaskEmail(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -282,12 +282,16 @@ func isEncryptedStorageValue(value string) bool {
} }
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) { func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
// 1. Validate timestamp (prevent replay attacks) // 1. Validate timestamp (prevent replay attacks).
if payload.TS != 0 { // The timestamp is mandatory: a missing/zero ts previously skipped this check
elapsed := time.Since(time.Unix(payload.TS, 0)) // entirely, which let a captured ciphertext be replayed indefinitely. The
if elapsed > 5*time.Minute || elapsed < -1*time.Minute { // client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
return nil, errors.New("timestamp invalid or expired") 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 // 2. Decode base64url
@@ -455,8 +459,11 @@ func (es EncryptedString) Value() (driver.Value, error) {
if globalCryptoService != nil { if globalCryptoService != nil {
encrypted, err := globalCryptoService.EncryptForStorage(string(es)) encrypted, err := globalCryptoService.EncryptForStorage(string(es))
if err != nil { if err != nil {
// If encryption fails, return the original value // Fail closed: never silently persist a plaintext secret when
return string(es), nil // 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 return encrypted, nil
} }

4
go.mod
View File

@@ -1,6 +1,6 @@
module nofx module nofx
go 1.25.10 go 1.25.11
require ( require (
github.com/adshao/go-binance/v2 v2.8.9 github.com/adshao/go-binance/v2 v2.8.9
@@ -22,6 +22,7 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0 golang.org/x/net v0.55.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0 golang.org/x/text v0.37.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite 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/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.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 google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.1 // indirect howett.net/plist v1.0.1 // indirect

View File

@@ -179,24 +179,12 @@ export class CryptoService {
return data.public_key || '' return data.public_key || ''
} }
static async decryptSensitiveData( // NOTE: there is intentionally no decryptSensitiveData() here. Transport
payload: EncryptedPayload // encryption is one-directional: the client encrypts sensitive fields to the
): Promise<string> { // server's public key and the server decrypts them internally on the
const response = await fetch('/api/crypto/decrypt', { // authenticated config endpoints. The server exposes no public decrypt route,
method: 'POST', // so a client-side decrypt helper would be both useless and a security
headers: { // anti-pattern (it implied an unauthenticated decryption oracle existed).
'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
}
} }
// 生成混淆字符串(用于剪贴板混淆) // 生成混淆字符串(用于剪贴板混淆)