mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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:
13
.github/workflows/pr-checks.yml
vendored
13
.github/workflows/pr-checks.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
100
api/server.go
100
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")
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
5
main.go
5
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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user