fix(security): harden auth flows and lock down telegram bot tool

- config: require JWT_SECRET >=32 bytes and reject the historical
  default fallback; MustInit aborts startup under an insecure config
- api: CORS now uses CORS_ALLOWED_ORIGINS allowlist with safe
  localhost defaults instead of returning Access-Control-Allow-Origin: *
- api: /api/reset-password and /api/reset-account stay public so
  recovery still works, but require an explicit confirm phrase in the
  body to block accidental and drive-by triggers
- api: drop adoptOrphanRecords so wiping the account no longer hands
  the next registrant the previous owner's wallet keys and exchange
  API credentials
- api: getTraderFromQuery now does a soft ownership check; equity-history
  is restricted to traders with show_in_competition=true and
  GetOrderFills joins on trader_id
- telegram: bot api_request tool uses a default-deny method+path
  allowlist so prompt injection cannot reach password, exchange key,
  AI provider or wallet endpoints
- ci: drop @master / @main on trivy-action and trufflehog; pin to
  released versions with a TODO to move to SHA + Dependabot
- web: reset flows send the required confirm phrase; "Forgot account"
  copy (en/zh/id) warns that wallet and exchange keys will be lost
- docker-compose: keep ./.env mount for onboarding wallet persistence
  with an inline note on the tradeoff, drop the host-exposed pprof port
This commit is contained in:
tinkle-community
2026-05-29 07:51:26 +08:00
parent 70db3f5ba3
commit 99361cb085
13 changed files with 379 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
}
// 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]
return s.traderManager, ids[0], nil
}
// 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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

@@ -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)

View File

@@ -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')

View File

@@ -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',
}),
})

View File

@@ -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',