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

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