Files
nofx/telegram/agent/apicall.go
tinkle-community 99361cb085 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
2026-05-29 07:51:26 +08:00

201 lines
7.6 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"regexp"
"strings"
"time"
)
// apiCallTool executes HTTP requests against the NOFX API server.
// This is the only tool available to the agent.
type apiCallTool struct {
baseURL string
token string
client *http.Client
}
// apiRequest holds the arguments decoded from the LLM's api_request tool call.
type apiRequest struct {
Method string `json:"method"`
Path string `json:"path"`
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),
token: token,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// execute calls the API and returns the response as a string for LLM consumption.
func (t *apiCallTool) execute(req *apiRequest) string {
if req.Method == "" || req.Path == "" {
return "error: method and path are required"
}
if !strings.HasPrefix(req.Path, "/") {
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)
if err != nil {
return fmt.Sprintf("error marshaling body: %v", err)
}
bodyReader = bytes.NewReader(b)
}
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
if err != nil {
return fmt.Sprintf("error creating request: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+t.token)
resp, err := t.client.Do(httpReq)
if err != nil {
return fmt.Sprintf("API call failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Sprintf("error reading response: %v", err)
}
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
if resp.StatusCode >= 400 {
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
}
// Pretty-print JSON for better LLM readability
var v any
if json.Unmarshal(body, &v) == nil {
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
return string(pretty)
}
}
return string(body)
}