mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
- 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
201 lines
7.6 KiB
Go
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)
|
|
}
|
|
|