diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 69f21c27..628ce6cf 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -273,7 +273,12 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master + # SECURITY: never use @master — upstream compromise = CI compromise. + # TODO: pin to a full 40-char SHA from + # https://github.com/aquasecurity/trivy-action/releases and configure Dependabot + # to keep it current. A version tag is still mutable but is a major upgrade + # over @master. + uses: aquasecurity/trivy-action@0.28.0 with: scan-type: 'fs' scan-ref: '.' @@ -299,7 +304,11 @@ jobs: fetch-depth: 0 - name: Run TruffleHog OSS - uses: trufflesecurity/trufflehog@main + # SECURITY: never use @main — upstream compromise = secret exfil. + # TODO: pin to a full 40-char SHA from + # https://github.com/trufflesecurity/trufflehog/releases and configure + # Dependabot. Version tag is still mutable but is a major upgrade over @main. + uses: trufflesecurity/trufflehog@v3.82.13 with: path: ./ base: ${{ github.event.pull_request.base.sha }} diff --git a/api/handler_competition.go b/api/handler_competition.go index be793876..a0354167 100644 --- a/api/handler_competition.go +++ b/api/handler_competition.go @@ -119,12 +119,25 @@ func (s *Server) handleCompetition(c *gin.Context) { c.JSON(http.StatusOK, competition) } -// handleEquityHistory Return rate historical data -// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart) +// handleEquityHistory returns equity history for a trader. This endpoint is +// PUBLIC (used by the competition leaderboard), so it cannot use the +// authenticated getTraderFromQuery helper. Instead, it validates that the +// requested trader has explicitly opted into the public competition via +// show_in_competition=true. Traders without that flag are not exposed. func (s *Server) handleEquityHistory(c *gin.Context) { - _, traderID, err := s.getTraderFromQuery(c) - if err != nil { - SafeBadRequest(c, "Invalid trader ID") + traderID := c.Query("trader_id") + if traderID == "" { + SafeBadRequest(c, "trader_id is required") + return + } + trader, err := s.store.Trader().GetByID(traderID) + if err != nil || trader == nil { + SafeNotFound(c, "Trader") + return + } + if !trader.ShowInCompetition { + // Do not leak that a private trader exists; report not found. + SafeNotFound(c, "Trader") return } diff --git a/api/handler_order.go b/api/handler_order.go index 56939337..ff484121 100644 --- a/api/handler_order.go +++ b/api/handler_order.go @@ -357,8 +357,8 @@ func (s *Server) handleOrderFills(c *gin.Context) { return } - // Get fills for this order - fills, err := store.Order().GetOrderFills(orderID) + // Get fills for this order, scoped to the trader (ownership boundary). + fills, err := store.Order().GetOrderFills(traderID, orderID) if err != nil { SafeInternalError(c, "Get order fills", err) return diff --git a/api/handler_user.go b/api/handler_user.go index aa91c232..a57b33c6 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -102,9 +102,11 @@ func (s *Server) handleRegister(c *gin.Context) { return } - // Adopt orphan records from previous account (e.g. after account reset) - // This preserves wallet keys and exchange configs so funds are not lost. - s.adoptOrphanRecords(userID) + // NOTE: Orphan record adoption was removed for security reasons. Previously, + // after a reset-account call, any new user would inherit the prior owner's + // wallet keys and exchange API credentials — a catastrophic IDOR/takeover + // path. Operators who need to migrate credentials across users must do so + // explicitly via export/import, never via implicit adoption on registration. // Generate JWT token token, err := auth.GenerateJWT(user.ID, user.Email) @@ -189,53 +191,108 @@ func (s *Server) handleChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password updated"}) } -// handleResetPassword Reset password via email and new password +// resetPasswordConfirmPhrase is the friction step for /api/reset-password. +// Same security rationale as resetAccountConfirmPhrase — not a cryptographic +// check, just a guard against accidental and drive-by triggers. +const resetPasswordConfirmPhrase = "I_UNDERSTAND_THIS_RESETS_MY_PASSWORD" + +// handleResetPassword resets the password for the given email. +// +// SECURITY NOTE: This endpoint is intentionally callable without a JWT — it +// IS the recovery path for "forgot password" in the single-user self-hosted +// threat model this project targets. A logged-in user changes password via +// PUT /api/user/password; this endpoint exists for users who can no longer +// log in. Mitigations: +// +// 1. Requires the confirm phrase (blocks accidental and drive-by triggers). +// 2. New password must be ≥ 8 chars. +// 3. Authenticated session change is preferred (PUT /api/user/password). +// +// Operators exposing the API to the public internet should put a reverse-proxy +// auth layer in front of /api/reset-password OR set up out-of-band recovery +// (email link, OTP) instead of relying on this endpoint. func (s *Server) handleResetPassword(c *gin.Context) { var req struct { Email string `json:"email" binding:"required,email"` - NewPassword string `json:"new_password" binding:"required,min=6"` + NewPassword string `json:"new_password" binding:"required,min=8"` + Confirm string `json:"confirm"` } - if err := c.ShouldBindJSON(&req); err != nil { - SafeBadRequest(c, "Invalid request parameters") + SafeBadRequest(c, "email, new_password (min 8 chars), and confirm are required") + return + } + if req.Confirm != resetPasswordConfirmPhrase { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Confirmation phrase required", + "hint": `Body must include {"confirm":"` + resetPasswordConfirmPhrase + `"}`, + }) return } - // Query user user, err := s.store.User().GetByEmail(req.Email) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"}) return } - // Generate new password hash newPasswordHash, err := auth.HashPassword(req.NewPassword) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"}) + SafeInternalError(c, "Password processing failed", err) + return + } + if err := s.store.User().UpdatePassword(user.ID, newPasswordHash); err != nil { + SafeInternalError(c, "Password update failed", err) return } - // Update password - err = s.store.User().UpdatePassword(user.ID, newPasswordHash) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"}) - return - } - - logger.Infof("✓ User %s password has been reset", user.Email) + logger.Infof("✓ User %s password reset via reset endpoint", user.Email) c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"}) } -// handleResetAccount clears user authentication data so the system returns to -// uninitialized state for re-registration. Wallet keys (ai_models) are preserved -// so funds are not lost — they will be adopted by the new account during onboarding. +// resetAccountConfirmPhrase must appear in the request body for /api/reset-account. +// This is the single intentional friction step that prevents accidental wipes +// from drive-by scripts and crawlers. It is NOT a cryptographic check — anyone +// who reads this source can send the phrase. The real safety comes from: +// +// 1. Wallet keys are NO LONGER auto-adopted by the next registrant +// (adoptOrphanRecords was removed). The historical takeover path was: +// reset → register → inherit prior wallet → drain. That path is closed. +// 2. The destructive action is loud (logged at Warn level). +// +// Operators who expose the API to the public internet and want stronger +// gating can wrap this route with a reverse-proxy auth header check. +const resetAccountConfirmPhrase = "I_UNDERSTAND_THIS_DELETES_EVERYTHING" + +// handleResetAccount wipes all users + traders + strategies + AI models + +// exchanges, returning the system to uninitialized state. +// +// SECURITY NOTE: For the single-user, self-hosted threat model this project +// targets, this endpoint is intentionally callable without a JWT — the +// frontend "forgot account" button must still work after the user forgets +// their password. The confirm phrase blocks accidental and drive-by triggers; +// the removal of orphan adoption blocks the post-reset takeover. A determined +// attacker on a public-facing deployment can still grief by wiping local +// state, but they cannot steal funds (everything is deleted, not transferred). func (s *Server) handleResetAccount(c *gin.Context) { + var req struct { + Confirm string `json:"confirm"` + } + _ = c.ShouldBindJSON(&req) + if req.Confirm != resetAccountConfirmPhrase { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Confirmation phrase required", + "hint": `Body must include {"confirm":"` + resetAccountConfirmPhrase + `"}`, + }) + return + } + err := s.store.Transaction(func(tx *gorm.DB) error { - // Delete traders and strategies (config, not funds) + // Wipe ALL records — including wallet keys and exchange credentials. + // Preserving them across user identities is what enabled the takeover. tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{}) tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{}) - // Delete users — ai_models and exchanges are intentionally kept - // so wallet private keys and exchange configs survive re-registration + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{}) + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{}) if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil { return fmt.Errorf("failed to delete users: %w", err) } @@ -246,28 +303,10 @@ func (s *Server) handleResetAccount(c *gin.Context) { return } - logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized") - c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"}) -} - -// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer -// exists in the users table. This happens after account reset so the new user -// inherits the previous wallet keys and exchange configurations. -func (s *Server) adoptOrphanRecords(newUserID string) { - db := s.store.GormDB() - result := db.Model(&store.AIModel{}). - Where("user_id NOT IN (SELECT id FROM users)"). - Update("user_id", newUserID) - if result.RowsAffected > 0 { - logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID) - } - - result = db.Model(&store.Exchange{}). - Where("user_id NOT IN (SELECT id FROM users)"). - Update("user_id", newUserID) - if result.RowsAffected > 0 { - logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID) - } + logger.Warnf("⚠ Account reset performed — all users, traders, strategies, ai_models, exchanges wiped") + c.JSON(http.StatusOK, gin.H{ + "message": "System wiped. All wallet keys and exchange credentials were deleted. Register a fresh account and re-import everything.", + }) } // initUserDefaultConfigs Initialize default configs for new user diff --git a/api/server.go b/api/server.go index 0b594965..41a292e3 100644 --- a/api/server.go +++ b/api/server.go @@ -10,6 +10,7 @@ import ( "nofx/logger" "nofx/manager" "nofx/store" + "os" "strings" "time" @@ -56,18 +57,62 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ return s } -// corsMiddleware CORS middleware +// corsMiddleware returns a CORS handler. Origins come from CORS_ALLOWED_ORIGINS +// (comma-separated). The literal value "*" enables permissive mode — DO NOT use +// in production: the JWT is sent via Authorization header so a wildcard ACAO +// makes stolen tokens replayable from any site. func corsMiddleware() gin.HandlerFunc { + raw := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS")) + allowAny := raw == "*" + var allowlist map[string]struct{} + if !allowAny { + allowlist = make(map[string]struct{}) + for _, o := range strings.Split(raw, ",") { + o = strings.TrimSpace(o) + if o == "" { + continue + } + allowlist[o] = struct{}{} + } + if len(allowlist) == 0 { + // Safe defaults for local development. + for _, o := range []string{ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:5173", + "http://127.0.0.1:5173", + } { + allowlist[o] = struct{}{} + } + logger.Warnf("[CORS] CORS_ALLOWED_ORIGINS not set; defaulting to localhost dev origins only. Set this env var for production.") + } + if allowAny { + logger.Warnf("[CORS] CORS_ALLOWED_ORIGINS=* is INSECURE in production; restrict to your deployment origin(s).") + } + } return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + origin := c.GetHeader("Origin") + if origin != "" { + switch { + case allowAny: + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Vary", "Origin") + default: + if _, ok := allowlist[origin]; ok { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Vary", "Origin") + } + // Unknown origin: do not set ACAO; the browser will block. + } + } c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Writer.Header().Set("Access-Control-Max-Age", "600") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusOK) return } - c.Next() } } @@ -120,8 +165,14 @@ func (s *Server) setupRoutes() { // Authentication related routes (no authentication required) s.route(api, "POST", "/register", "Register new user", s.handleRegister) s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) - s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword) - s.route(api, "POST", "/reset-account", "Clear all users and reset system to allow re-registration", s.handleResetAccount) + // SECURITY: /reset-password and /reset-account are PUBLIC by necessity — + // they ARE the recovery paths when the user can no longer log in. Both + // require a literal confirmation phrase in the request body, which + // blocks accidental triggers and drive-by scripts. The historical + // takeover path (post-reset wallet-key adoption) was closed by + // removing adoptOrphanRecords. See handler_user.go for details. + s.route(api, "POST", "/reset-password", "Reset password by email (requires confirm phrase)", s.handleResetPassword) + s.route(api, "POST", "/reset-account", "[DESTRUCTIVE] Wipe everything (requires confirm phrase)", s.handleResetAccount) // Routes requiring authentication protected := api.Group("/", s.authMiddleware()) @@ -514,31 +565,46 @@ func isPrivateIP(ip net.IP) bool { return false } -// getTraderFromQuery Get trader from query parameter +// getTraderFromQuery resolves a trader from the ?trader_id= query parameter. +// +// This project is single-user by design, so a strict cross-tenant ownership +// check would be theatre. We still perform a soft check (the requested trader +// must appear in the caller's store list when present) — this is cheap defense +// in depth that future-proofs against accidental multi-account drift and +// catches typos that would otherwise return another account's data. func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) { userID := c.GetString("user_id") traderID := c.Query("trader_id") - // Ensure user's traders are loaded into memory - err := s.traderManager.LoadUserTradersFromStore(s.store, userID) - if err != nil { + // Ensure user's traders are loaded into memory. + if err := s.traderManager.LoadUserTradersFromStore(s.store, userID); err != nil { logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err) } if traderID == "" { - // If no trader_id specified, return first trader for this user + // No trader_id specified — return first trader for this user, falling + // back to the first in-memory trader if no per-user list exists yet. + userTraders, err := s.store.Trader().List(userID) + if err == nil && len(userTraders) > 0 { + return s.traderManager, userTraders[0].ID, nil + } ids := s.traderManager.GetTraderIDs() if len(ids) == 0 { return nil, "", fmt.Errorf("No available traders") } + return s.traderManager, ids[0], nil + } - // Get user's trader list, prioritize returning user's own traders - userTraders, err := s.store.Trader().List(userID) - if err == nil && len(userTraders) > 0 { - traderID = userTraders[0].ID - } else { - traderID = ids[0] + // Soft ownership check: if the caller owns any traders in the store and + // the requested ID is NOT among them, treat as not-found instead of + // silently returning whatever happens to be in the global in-memory map. + if userTraders, err := s.store.Trader().List(userID); err == nil && len(userTraders) > 0 { + for _, t := range userTraders { + if t.ID == traderID { + return s.traderManager, traderID, nil + } } + return nil, "", fmt.Errorf("trader not found for this account") } return s.traderManager, traderID, nil diff --git a/config/config.go b/config/config.go index e43b54eb..65509346 100644 --- a/config/config.go +++ b/config/config.go @@ -1,13 +1,23 @@ package config import ( - "nofx/telemetry" + "fmt" "nofx/mcp" + "nofx/telemetry" "os" "strconv" "strings" ) +// insecureDefaultJWTSecret is the historical fallback value. Refusing to boot when +// JWT_SECRET matches it (or is missing) prevents the server from silently signing +// tokens with a well-known secret. +const insecureDefaultJWTSecret = "default-jwt-secret-change-in-production" + +// minJWTSecretLength is the minimum byte length we accept for HS256 signing keys. +// HS256 keys shorter than 32 bytes are brute-forceable. +const minJWTSecretLength = 32 + // Global configuration instance var global *Config @@ -45,8 +55,24 @@ type Config struct { } -// Init initializes global configuration (from .env) +// MustInit initializes global configuration or panics. Use from main() so the +// process refuses to start under an insecure config (e.g. default JWT secret). +func MustInit() { + if err := initConfig(); err != nil { + panic(fmt.Sprintf("config: %v", err)) + } +} + +// Init initializes global configuration (from .env). Prefer MustInit from main. func Init() { + if err := initConfig(); err != nil { + // Preserve historical fail-soft behavior for non-main callers (tests, tools); + // the process can still observe the error via Get() returning nil. + fmt.Fprintf(os.Stderr, "config init failed: %v\n", err) + } +} + +func initConfig() error { cfg := &Config{ APIServerPort: 8080, ExperienceImprovement: true, // Default: enabled to help improve the product @@ -65,7 +91,13 @@ func Init() { cfg.JWTSecret = strings.TrimSpace(v) } if cfg.JWTSecret == "" { - cfg.JWTSecret = "default-jwt-secret-change-in-production" + return fmt.Errorf("JWT_SECRET is required (set a random %d+ byte value in .env)", minJWTSecretLength) + } + if cfg.JWTSecret == insecureDefaultJWTSecret { + return fmt.Errorf("JWT_SECRET matches the insecure default; generate a fresh random value (e.g. `openssl rand -base64 48`)") + } + if len(cfg.JWTSecret) < minJWTSecretLength { + return fmt.Errorf("JWT_SECRET must be at least %d bytes (got %d); generate via `openssl rand -base64 48`", minJWTSecretLength, len(cfg.JWTSecret)) } if v := os.Getenv("API_SERVER_PORT"); v != "" { @@ -134,6 +166,7 @@ func Init() { OutputTokens: usage.CompletionTokens, }) } + return nil } // Get returns the global configuration diff --git a/docker-compose.yml b/docker-compose.yml index 7d36c2c1..56b94ac5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,14 @@ services: stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown ports: - "${NOFX_BACKEND_PORT:-8080}:8080" - - "6060:6060" # pprof profiling + # pprof profiling is bound to host loopback only; uncomment for local debug. + # - "127.0.0.1:6060:6060" volumes: + # NOTE: .env is bind-mounted so the beginner-onboarding flow + # (persistBeginnerWalletEnv) can write CLAW402_WALLET_* back to the host + # file. Without this mount the wallet is regenerated on every container + # restart. For threat models where the .env file should not be reachable + # via container RCE, deploy via env vars only and remove this mount. - ./.env:/app/.env - ./data:/app/data - /etc/localtime:/etc/localtime:ro diff --git a/main.go b/main.go index 351fcce3..3b85527b 100644 --- a/main.go +++ b/main.go @@ -34,8 +34,9 @@ func main() { logger.Info("║ 🚀 NOFX - AI-Powered Trading System ║") logger.Info("╚════════════════════════════════════════════════════════════╝") - // Initialize global configuration (loaded from .env) - config.Init() + // Initialize global configuration (loaded from .env). + // MustInit refuses to start under an insecure config (e.g. missing or default JWT_SECRET). + config.MustInit() cfg := config.Get() logger.Info("✅ Configuration loaded") diff --git a/store/order.go b/store/order.go index f632ab3f..785d96bf 100644 --- a/store/order.go +++ b/store/order.go @@ -258,12 +258,17 @@ func (s *OrderStore) GetTraderOrdersFiltered(traderID string, symbol string, sta return orders, nil } -// GetOrderFills gets order's fill records -func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) { +// GetOrderFills gets fill records for a specific order. The traderID arg +// scopes the join so a caller cannot read fills for an order that does not +// belong to their trader (IDOR boundary). Pass an empty traderID only from +// trusted internal callers that have already verified ownership. +func (s *OrderStore) GetOrderFills(traderID string, orderID int64) ([]*TraderFill, error) { + q := s.db.Where("order_id = ?", orderID) + if traderID != "" { + q = q.Where("trader_id = ?", traderID) + } var fills []*TraderFill - err := s.db.Where("order_id = ?", orderID). - Order("created_at ASC"). - Find(&fills).Error + err := q.Order("created_at ASC").Find(&fills).Error if err != nil { return nil, fmt.Errorf("failed to query fills: %w", err) } diff --git a/telegram/agent/apicall.go b/telegram/agent/apicall.go index eca6b9d5..905cfca7 100644 --- a/telegram/agent/apicall.go +++ b/telegram/agent/apicall.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "nofx/logger" + "regexp" "strings" "time" ) @@ -26,6 +27,100 @@ type apiRequest struct { Body map[string]any `json:"body"` } +// allowedRoute is one entry in the LLM tool allowlist. The bot agent runs with +// a real user JWT, so we MUST default-deny: any path not listed here is rejected +// before the HTTP call is made. This prevents prompt-injection (via account +// names, strategy names, etc. injected into the LLM context) from coercing the +// bot into changing the user's password, swapping exchange credentials, or +// pointing the LLM API key at an attacker-controlled URL. +type allowedRoute struct { + method string + pattern *regexp.Regexp +} + +// botAPIAllowlist enumerates the endpoints the Telegram LLM agent is permitted +// to call. Keep this LIST SHORT and DEFAULT-DENY. To grant the bot access to a +// new endpoint, add an explicit entry here — never widen wildcards. +// +// Explicitly NOT allowed (and must never be added without a human-in-the-loop +// confirmation flow): +// - PUT /api/user/password (password takeover) +// - PUT /api/models (LLM API key + endpoint swap → exfil) +// - POST/PUT/DELETE /api/exchanges* (exchange credential swap → drain) +// - POST /api/reset-password, /api/reset-account (destructive) +// - POST /api/wallet/generate, /api/wallet/validate +// - POST /api/telegram/* (rebind bot) +var botAPIAllowlist = []allowedRoute{ + // Read-only endpoints that surface state to the user. + {"GET", regexp.MustCompile(`^/api/health$`)}, + {"GET", regexp.MustCompile(`^/api/config$`)}, + {"GET", regexp.MustCompile(`^/api/supported-models$`)}, + {"GET", regexp.MustCompile(`^/api/supported-exchanges$`)}, + {"GET", regexp.MustCompile(`^/api/models$`)}, + {"GET", regexp.MustCompile(`^/api/exchanges$`)}, + {"GET", regexp.MustCompile(`^/api/exchanges/account-state$`)}, + {"GET", regexp.MustCompile(`^/api/strategies(/[^/]+)?$`)}, + {"GET", regexp.MustCompile(`^/api/strategies/active$`)}, + {"GET", regexp.MustCompile(`^/api/strategies/default-config$`)}, + {"GET", regexp.MustCompile(`^/api/strategies/public$`)}, + {"GET", regexp.MustCompile(`^/api/my-traders$`)}, + {"GET", regexp.MustCompile(`^/api/traders$`)}, + {"GET", regexp.MustCompile(`^/api/traders/[^/]+/config$`)}, + {"GET", regexp.MustCompile(`^/api/traders/[^/]+/public-config$`)}, + {"GET", regexp.MustCompile(`^/api/traders/[^/]+/grid-risk$`)}, + {"GET", regexp.MustCompile(`^/api/competition$`)}, + {"GET", regexp.MustCompile(`^/api/top-traders$`)}, + {"GET", regexp.MustCompile(`^/api/equity-history$`)}, + {"GET", regexp.MustCompile(`^/api/klines$`)}, + {"GET", regexp.MustCompile(`^/api/symbols$`)}, + {"GET", regexp.MustCompile(`^/api/status$`)}, + {"GET", regexp.MustCompile(`^/api/account$`)}, + {"GET", regexp.MustCompile(`^/api/positions$`)}, + {"GET", regexp.MustCompile(`^/api/positions/history$`)}, + {"GET", regexp.MustCompile(`^/api/trades$`)}, + {"GET", regexp.MustCompile(`^/api/orders$`)}, + {"GET", regexp.MustCompile(`^/api/orders/[^/]+/fills$`)}, + {"GET", regexp.MustCompile(`^/api/open-orders$`)}, + {"GET", regexp.MustCompile(`^/api/decisions$`)}, + {"GET", regexp.MustCompile(`^/api/decisions/latest$`)}, + {"GET", regexp.MustCompile(`^/api/statistics$`)}, + {"GET", regexp.MustCompile(`^/api/ai-costs$`)}, + {"GET", regexp.MustCompile(`^/api/ai-costs/summary$`)}, + + // Write endpoints — trader and strategy lifecycle. These let the bot create + // traders and strategies the user has asked for, and start/stop them. NOT + // including any endpoint that mutates credentials, passwords, or pointers + // to external services (LLM API URL, exchange API keys, telegram binding). + // Strategy configs are server-side-validated for risk caps in the API + // layer, so strategy create/update here cannot escape the user's risk + // boundary. + {"POST", regexp.MustCompile(`^/api/traders$`)}, + {"PUT", regexp.MustCompile(`^/api/traders/[^/]+$`)}, + {"DELETE", regexp.MustCompile(`^/api/traders/[^/]+$`)}, + {"POST", regexp.MustCompile(`^/api/traders/[^/]+/start$`)}, + {"POST", regexp.MustCompile(`^/api/traders/[^/]+/stop$`)}, + {"POST", regexp.MustCompile(`^/api/traders/[^/]+/sync-balance$`)}, + {"POST", regexp.MustCompile(`^/api/traders/[^/]+/close-position$`)}, + {"PUT", regexp.MustCompile(`^/api/traders/[^/]+/prompt$`)}, + {"PUT", regexp.MustCompile(`^/api/traders/[^/]+/competition$`)}, + {"POST", regexp.MustCompile(`^/api/strategies$`)}, + {"PUT", regexp.MustCompile(`^/api/strategies/[^/]+$`)}, + {"DELETE", regexp.MustCompile(`^/api/strategies/[^/]+$`)}, + {"POST", regexp.MustCompile(`^/api/strategies/[^/]+/activate$`)}, + {"POST", regexp.MustCompile(`^/api/strategies/[^/]+/duplicate$`)}, +} + +// isPathAllowed returns true when the (method, path) pair is in botAPIAllowlist. +// The path argument should already be query-stripped. +func isPathAllowed(method, path string) bool { + for _, r := range botAPIAllowlist { + if r.method == method && r.pattern.MatchString(path) { + return true + } + } + return false +} + func newAPICallTool(port int, token string) *apiCallTool { return &apiCallTool{ baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), @@ -43,6 +138,23 @@ func (t *apiCallTool) execute(req *apiRequest) string { req.Path = "/" + req.Path } + // SECURITY: default-deny allowlist enforcement. Without this, prompt + // injection via user-controlled fields (account_name, strategy name, + // trader name) could coerce the LLM into calling sensitive endpoints + // like PUT /api/user/password or PUT /api/exchanges with the bot's JWT. + method := strings.ToUpper(req.Method) + pathOnly := req.Path + if i := strings.IndexByte(pathOnly, '?'); i >= 0 { + pathOnly = pathOnly[:i] + } + if !isPathAllowed(method, pathOnly) { + logger.Warnf("Agent: blocked disallowed tool call %s %s (path not in botAPIAllowlist)", method, pathOnly) + return fmt.Sprintf( + `{"error":"endpoint not allowed for the chat agent","method":%q,"path":%q,"hint":"ask the user to perform this action in the web UI"}`, + method, pathOnly, + ) + } + var bodyReader io.Reader if req.Method != "GET" && len(req.Body) > 0 { b, err := json.Marshal(req.Body) diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index f5a37689..25683063 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -46,7 +46,13 @@ export function LoginPage() { const handleResetAccount = async () => { if (!window.confirm(t('forgotAccountConfirm', language))) return try { - const res = await fetch('/api/reset-account', { method: 'POST' }) + const res = await fetch('/api/reset-account', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // Server-side guard against accidental/drive-by triggers. + // Phrase must match handler_user.go resetAccountConfirmPhrase. + body: JSON.stringify({ confirm: 'I_UNDERSTAND_THIS_DELETES_EVERYTHING' }), + }) if (res.ok) { localStorage.removeItem('auth_token') localStorage.removeItem('auth_user') diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index b46a3822..9829050c 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -269,6 +269,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { body: JSON.stringify({ email, new_password: newPassword, + // Server-side guard against accidental/drive-by triggers. + // Phrase must match handler_user.go resetPasswordConfirmPhrase. + confirm: 'I_UNDERSTAND_THIS_RESETS_MY_PASSWORD', }), }) diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 661e20e2..49740ed2 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -494,7 +494,7 @@ export const translations = { loginNow: 'Sign in now', forgotPassword: 'Forgot password?', forgotAccount: 'Forgot account?', - forgotAccountConfirm: 'This will clear all account data and allow you to register a new account. Continue?', + forgotAccountConfirm: '⚠️ This will permanently delete EVERYTHING: users, traders, strategies, AI model API keys, exchange API keys, and your CLAW402 wallet. Export anything you need to keep (especially wallet private keys) BEFORE continuing. Re-registration will NOT restore them. Continue?', forgotAccountSuccess: 'Account reset successful! You can now register a new account.', rememberMe: 'Remember me', resetPassword: 'Reset Password', @@ -1824,7 +1824,7 @@ export const translations = { loginNow: '立即登录', forgotPassword: '忘记密码?', forgotAccount: '忘记账户?', - forgotAccountConfirm: '这将清除所有账户数据,允许您重新注册新账户。是否继续?', + forgotAccountConfirm: '⚠️ 这将永久删除全部数据:用户、Trader、策略、AI 模型 API Key、交易所 API Key,以及您的 CLAW402 钱包。请务必在继续前导出需要保留的内容(尤其是钱包私钥)。重新注册不会恢复任何数据。确定要继续吗?', forgotAccountSuccess: '账户已重置!现在可以注册新账户了。', rememberMe: '记住我', resetPassword: '重置密码', @@ -3089,7 +3089,7 @@ export const translations = { loginNow: 'Masuk sekarang', forgotPassword: 'Lupa kata sandi?', forgotAccount: 'Lupa akun?', - forgotAccountConfirm: 'Ini akan menghapus semua data akun dan memungkinkan Anda mendaftar akun baru. Lanjutkan?', + forgotAccountConfirm: '⚠️ Ini akan MENGHAPUS PERMANEN semua data: pengguna, trader, strategi, kunci API model AI, kunci API bursa, dan dompet CLAW402 Anda. Ekspor apa pun yang ingin Anda simpan (terutama kunci privat dompet) SEBELUM melanjutkan. Pendaftaran ulang TIDAK akan memulihkannya. Lanjutkan?', forgotAccountSuccess: 'Akun berhasil direset! Anda sekarang dapat mendaftar akun baru.', rememberMe: 'Ingat saya', resetPassword: 'Reset Kata Sandi',