2 Commits

Author SHA1 Message Date
tinkle-community
220cb7428b fix(deps): resolve 3 critical Dependabot advisories
- go: bump github.com/jackc/pgx/v5 v5.6.0 -> v5.9.0 (CVE-2026-33815 /
  CVE-2026-33816, memory-safety in the Postgres driver). govulncheck reports
  0 affecting vulnerabilities after the bump.
- ci: pin aquasecurity/trivy-action to commit SHA ed142fd (v0.36.0) instead of
  the mutable @0.28.0 tag (GHSA-69fq-xp46-6x23, brief upstream supply-chain
  compromise). Dependabot now updates the SHA.
- web: bump vitest ^4.0.16 -> ^4.1.0 (lockfile now 4.1.8) for
  GHSA-5xrq-8626-4rwp (Vitest UI server arbitrary file read/exec; dev-only).
2026-06-05 22:19:27 +08:00
tinkle-community
1aea7abc38 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).
2026-06-05 22:08:26 +08:00
17 changed files with 495 additions and 228 deletions

View File

@@ -273,12 +273,11 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner - name: Run Trivy vulnerability scanner
# SECURITY: never use @master — upstream compromise = CI compromise. # SECURITY: pinned to a full 40-char commit SHA (v0.36.0) — a mutable
# TODO: pin to a full 40-char SHA from # version tag could be re-pointed by an upstream compromise (GHSA-69fq-xp46-6x23:
# https://github.com/aquasecurity/trivy-action/releases and configure Dependabot # trivy-action's published artifacts were briefly poisoned). The trailing
# to keep it current. A version tag is still mutable but is a major upgrade # comment records the human-readable version; Dependabot updates the SHA.
# over @master. uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
uses: aquasecurity/trivy-action@0.28.0
with: with:
scan-type: 'fs' scan-type: 'fs'
scan-ref: '.' scan-ref: '.'

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
} }

6
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
@@ -57,7 +58,7 @@ require (
github.com/holiman/uint256 v1.3.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.9.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -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

4
go.sum
View File

@@ -104,8 +104,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=

146
web/package-lock.json generated
View File

@@ -53,7 +53,7 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^4.0.16" "vitest": "^4.1.0"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -2592,31 +2592,31 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.0.16", "@vitest/spy": "4.1.8",
"@vitest/utils": "4.0.16", "@vitest/utils": "4.1.8",
"chai": "^6.2.1", "chai": "^6.2.2",
"tinyrainbow": "^3.0.3" "tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "4.0.16", "@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@@ -2625,7 +2625,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"msw": "^2.4.9", "msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"msw": { "msw": {
@@ -2637,26 +2637,26 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tinyrainbow": "^3.0.3" "tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.16", "@vitest/utils": "4.1.8",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@@ -2664,13 +2664,14 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.0.16", "@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -2679,9 +2680,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -2689,14 +2690,15 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.0.16", "@vitest/pretty-format": "4.1.8",
"tinyrainbow": "^3.0.3" "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
@@ -4052,9 +4054,9 @@
} }
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -4557,9 +4559,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.2.2", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -7720,9 +7722,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -8179,9 +8181,9 @@
} }
}, },
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.0.3", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -8626,31 +8628,31 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.0.16", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.16", "@vitest/expect": "4.1.8",
"@vitest/mocker": "4.0.16", "@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.0.16", "@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.0.16", "@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.0.16", "@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.0.16", "@vitest/spy": "4.1.8",
"@vitest/utils": "4.0.16", "@vitest/utils": "4.1.8",
"es-module-lexer": "^1.7.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.2.2", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"obug": "^2.1.1", "obug": "^2.1.1",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"std-env": "^3.10.0", "std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0", "tinybench": "^2.9.0",
"tinyexec": "^1.0.2", "tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3", "tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@@ -8666,12 +8668,15 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.16", "@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.0.16", "@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.0.16", "@vitest/browser-webdriverio": "4.1.8",
"@vitest/ui": "4.0.16", "@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@edge-runtime/vm": { "@edge-runtime/vm": {
@@ -8692,6 +8697,12 @@
"@vitest/browser-webdriverio": { "@vitest/browser-webdriverio": {
"optional": true "optional": true
}, },
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": { "@vitest/ui": {
"optional": true "optional": true
}, },
@@ -8700,6 +8711,9 @@
}, },
"jsdom": { "jsdom": {
"optional": true "optional": true
},
"vite": {
"optional": false
} }
} }
}, },

View File

@@ -59,7 +59,7 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^4.0.16" "vitest": "^4.1.0"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx}": [ "*.{ts,tsx}": [

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
}
} }
// 生成混淆字符串(用于剪贴板混淆) // 生成混淆字符串(用于剪贴板混淆)