Files
nofx/telegram/agent/apicall.go
tinkle-community 577a0918c3 fix(security): move account recovery to local CLI, remove unauthenticated reset endpoints
Unauthenticated POST /api/reset-password and /api/reset-account were a
remotely exploitable auth-bypass on public-facing deployments. The confirm
phrase was embedded in the frontend and echoed back by the API, so it was
friction, not authentication: anyone who knew the account email could reset
the password, log in, and obtain a valid JWT.

Recovery now runs as local CLI commands that operate directly on the database
without starting the HTTP server:

  nofx reset-password --email you@example.com
  nofx reset-account

These require shell/file access to the host, which a remote attacker does not
have, so recovery stays safe even when NOFX is exposed to the public internet.

- cli.go: new reset-password / reset-account subcommands (hidden password
  input on a TTY, --password/stdin for scripting, min 8 chars)
- main.go: dispatch subcommands before the server starts (backward compatible
  with the legacy `nofx <dbpath>` arg)
- api: remove public /reset-password and /reset-account routes, their handlers,
  and the public confirm-phrase constants
- web: replace the self-service reset form with CLI instructions; drop the
  AuthContext resetPassword call and the LoginPage reset-account call (en/zh/id)
- telegram: refresh the bot allowlist comment
2026-06-05 10:49:21 +08:00

202 lines
7.7 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)
// - account recovery (password reset / account wipe) is intentionally
// CLI-only (`nofx reset-password` / `nofx reset-account`) and has no
// HTTP endpoint, so the bot cannot reach it
// - 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)
}