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
|
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 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,19 @@ type SafeModelConfig struct {
|
|||||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateModelConfigRequest struct {
|
// ModelConfigUpdate is a single model's update payload. It is a named type
|
||||||
Models map[string]struct {
|
// (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"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
CustomAPIURL string `json:"custom_api_url"`
|
CustomAPIURL string `json:"custom_api_url"`
|
||||||
CustomModelName string `json:"custom_model_name"`
|
CustomModelName string `json:"custom_model_name"`
|
||||||
} `json:"models"`
|
}
|
||||||
|
|
||||||
|
type UpdateModelConfigRequest struct {
|
||||||
|
Models map[string]ModelConfigUpdate `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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,11 @@ func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateExchangeConfigRequest struct {
|
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
|
||||||
Exchanges map[string]struct {
|
// 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"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
SecretKey string `json:"secret_key"`
|
SecretKey string `json:"secret_key"`
|
||||||
@@ -86,7 +89,10 @@ type UpdateExchangeConfigRequest struct {
|
|||||||
LighterPrivateKey string `json:"lighter_private_key"`
|
LighterPrivateKey string `json:"lighter_private_key"`
|
||||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||||
} `json:"exchanges"`
|
}
|
||||||
|
|
||||||
|
type UpdateExchangeConfigRequest struct {
|
||||||
|
Exchanges map[string]ExchangeConfigUpdate `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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
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
|
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,24 +603,21 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if traderID == "" {
|
// Resolve strictly from the caller's own trader list.
|
||||||
// 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)
|
userTraders, err := s.store.Trader().List(userID)
|
||||||
if err == nil && len(userTraders) > 0 {
|
if err != nil {
|
||||||
return s.traderManager, userTraders[0].ID, nil
|
return nil, "", fmt.Errorf("failed to load traders for this account: %w", err)
|
||||||
}
|
}
|
||||||
ids := s.traderManager.GetTraderIDs()
|
if len(userTraders) == 0 {
|
||||||
if len(ids) == 0 {
|
|
||||||
return nil, "", fmt.Errorf("No available traders")
|
return nil, "", fmt.Errorf("No available traders")
|
||||||
}
|
}
|
||||||
return s.traderManager, ids[0], nil
|
|
||||||
|
if traderID == "" {
|
||||||
|
// 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
|
// A trader_id was supplied — it must belong to the caller.
|
||||||
// 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 {
|
for _, t := range userTraders {
|
||||||
if t.ID == traderID {
|
if t.ID == traderID {
|
||||||
return s.traderManager, traderID, nil
|
return s.traderManager, traderID, nil
|
||||||
@@ -606,9 +626,6 @@ func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, str
|
|||||||
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
|
||||||
func (s *Server) authMiddleware() gin.HandlerFunc {
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
36
api/utils.go
36
api/utils.go
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -282,13 +282,17 @@ 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
|
||||||
|
// 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))
|
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||||
return nil, errors.New("timestamp invalid or expired")
|
return nil, errors.New("timestamp invalid or expired")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Decode base64url
|
// 2. Decode base64url
|
||||||
wrappedKey, err := base64.RawURLEncoding.DecodeString(payload.WrappedKey)
|
wrappedKey, err := base64.RawURLEncoding.DecodeString(payload.WrappedKey)
|
||||||
@@ -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
4
go.mod
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成混淆字符串(用于剪贴板混淆)
|
// 生成混淆字符串(用于剪贴板混淆)
|
||||||
|
|||||||
Reference in New Issue
Block a user