mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Compare commits
2 Commits
577a0918c3
...
220cb7428b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
220cb7428b | ||
|
|
1aea7abc38 |
11
.github/workflows/pr-checks.yml
vendored
11
.github/workflows/pr-checks.yml
vendored
@@ -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: '.'
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,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
6
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
|
||||||
@@ -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
4
go.sum
@@ -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
146
web/package-lock.json
generated
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}": [
|
||||||
|
|||||||
@@ -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