From f26a69d222ba70342a2e562b14f096048ffe562e Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 09:53:49 +0800 Subject: [PATCH] security: configurable CORS origins, limit sentinel response body - Add CORS_ALLOWED_ORIGINS config (comma-separated origins, default allow-all) - CORS middleware now validates Origin header against allowlist - Add io.LimitReader to sentinel.go (was unbounded, now 256KB) - Include previous: agent chat auth middleware + frontend token header --- agent/sentinel.go | 2 +- agent/stock.go | 2 +- api/agent_routes.go | 5 ++++- api/server.go | 37 +++++++++++++++++++++++++++++++-- config/config.go | 8 +++++++ nofxi-tasks.md | 32 ++++++++++++++++++++++++++++ web/src/pages/AgentChatPage.tsx | 9 ++++++-- 7 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 nofxi-tasks.md diff --git a/agent/sentinel.go b/agent/sentinel.go index a5057c08..f8de41ae 100644 --- a/agent/sentinel.go +++ b/agent/sentinel.go @@ -115,7 +115,7 @@ func (s *Sentinel) check(symbol string) { resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)) if err != nil { return } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) // 256KB limit if err != nil { return } var t map[string]interface{} if err := json.Unmarshal(body, &t); err != nil { return } diff --git a/agent/stock.go b/agent/stock.go index 23e059bd..d03bb633 100644 --- a/agent/stock.go +++ b/agent/stock.go @@ -133,7 +133,7 @@ func searchStock(keyword string) ([]SearchResult, error) { } defer resp.Body.Close() - reader := transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder()) + reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder()) body, err := io.ReadAll(reader) if err != nil { return nil, err diff --git a/api/agent_routes.go b/api/agent_routes.go index 893197cf..6fabf148 100644 --- a/api/agent_routes.go +++ b/api/agent_routes.go @@ -7,8 +7,11 @@ import ( ) // RegisterAgentHandler registers NOFXi agent API routes on the main router. +// Chat endpoint requires authentication; market data endpoints are public. func (s *Server) RegisterAgentHandler(h *agent.WebHandler) { - s.router.POST("/api/agent/chat", gin.WrapF(h.HandleChat)) + // Chat requires auth — can trigger trades and access account data + s.router.POST("/api/agent/chat", s.authMiddleware(), gin.WrapF(h.HandleChat)) + // Public endpoints — read-only market data s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth)) s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines)) s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker)) diff --git a/api/server.go b/api/server.go index 2129b528..5cc5e6c0 100644 --- a/api/server.go +++ b/api/server.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "nofx/auth" + "nofx/config" "nofx/crypto" "nofx/logger" "nofx/manager" @@ -56,12 +57,44 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ return s } -// corsMiddleware CORS middleware +// corsMiddleware CORS middleware with configurable allowed origins. +// Set CORS_ALLOWED_ORIGINS env var to a comma-separated list of origins +// (e.g. "http://localhost:5173,https://nofx.example.com"). +// If empty or "*", all origins are allowed (development mode). func corsMiddleware() gin.HandlerFunc { + cfg := config.Get() + raw := strings.TrimSpace(cfg.CORSAllowedOrigins) + + // Build allowed origins set + allowAll := raw == "" || raw == "*" + var allowed map[string]bool + if !allowAll { + allowed = make(map[string]bool) + for _, o := range strings.Split(raw, ",") { + o = strings.TrimSpace(o) + if o != "" { + allowed[o] = true + } + } + if len(allowed) == 0 { + allowAll = true + } + } + return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + origin := c.Request.Header.Get("Origin") + if allowAll { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + } else if origin != "" && allowed[origin] { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Vary", "Origin") + } else if origin != "" { + // Origin not in allow list — reject preflight, allow simple requests + // but without CORS headers the browser will block the response. + } 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-Allow-Credentials", "true") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusOK) diff --git a/config/config.go b/config/config.go index e43b54eb..f525ce19 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,11 @@ type Config struct { // Set EXPERIENCE_IMPROVEMENT=false to disable ExperienceImprovement bool + // CORS configuration + // Comma-separated list of allowed origins. Empty or "*" means allow all. + // Example: "http://localhost:5173,https://nofx.example.com" + CORSAllowedOrigins string + // Market data provider API keys AlpacaAPIKey string // Alpaca API key for US stocks AlpacaSecretKey string // Alpaca secret key @@ -86,6 +91,9 @@ func Init() { cfg.ExperienceImprovement = strings.ToLower(v) != "false" } + // CORS allowed origins (comma-separated, empty = allow all) + cfg.CORSAllowedOrigins = os.Getenv("CORS_ALLOWED_ORIGINS") + // Market data provider API keys cfg.AlpacaAPIKey = os.Getenv("ALPACA_API_KEY") cfg.AlpacaSecretKey = os.Getenv("ALPACA_SECRET_KEY") diff --git a/nofxi-tasks.md b/nofxi-tasks.md new file mode 100644 index 00000000..e140f456 --- /dev/null +++ b/nofxi-tasks.md @@ -0,0 +1,32 @@ +# NOFXi Self-Drive Tasks + +## Completed + +### 2026-03-23 09:37 — Security Hardening Batch +- [DONE] Fix health endpoint returning `"time": null` → now returns proper RFC3339 timestamp +- [DONE] Add login rate limiting (5 attempts / 15min, 5min block) to prevent brute-force +- [DONE] Move `/api/reset-password` behind auth middleware (was unauthenticated — critical vuln) +- [DONE] Add response body size limits on proxy/external API calls (prevent memory exhaustion) +- [DONE] Sanitize error messages in agent chat endpoint (was leaking internal error details) +- [DONE] Record login success/failure for rate limiter tracking +- [DONE] Sanitize markdown link URLs in frontend (prevent javascript: XSS) + +## Pending + +### Security +- [PENDING] Investigate GitHub Dependabot's 21 reported vulnerabilities (13 high, 7 moderate, 1 low) +- [PENDING] Add CSRF protection or tighten CORS from wildcard `*` to specific origins +- [PENDING] Agent chat endpoint (`/api/agent/chat`) is not behind auth middleware — should require authentication + +### Code Quality +- [PENDING] The `resolveStockCodeDynamic` function at line 210 of `agent/stock.go` also has unlimited io.ReadAll (line 136) +- [PENDING] `context.Background()` used in ~30+ exchange trader calls — should propagate request context for proper cancellation +- [PENDING] `AgentChatPage.tsx` is 1001 lines — could be split into smaller components + +### Performance +- [PENDING] `gatherContext` in agent.go iterates all traders and positions on every message — consider caching +- [PENDING] News scan `seen` map grows unbounded, only reset at 1000 entries — use TTL-based expiry + +### Features +- [PENDING] Agent chat has fake streaming (word-by-word setTimeout) — implement real SSE streaming +- [PENDING] Add WebSocket support for real-time position/balance updates instead of polling diff --git a/web/src/pages/AgentChatPage.tsx b/web/src/pages/AgentChatPage.tsx index 887e43bd..ab6443e4 100644 --- a/web/src/pages/AgentChatPage.tsx +++ b/web/src/pages/AgentChatPage.tsx @@ -15,6 +15,7 @@ import { Search, } from 'lucide-react' import { useLanguage } from '../contexts/LanguageContext' +import { useAuth } from '../contexts/AuthContext' import { MarketTicker } from '../components/agent/MarketTicker' import { PositionsPanel } from '../components/agent/PositionsPanel' import { TraderStatusPanel } from '../components/agent/TraderStatusPanel' @@ -227,6 +228,7 @@ interface SuggestionCard { export function AgentChatPage() { const { language } = useLanguage() + const { token } = useAuth() const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') @@ -315,8 +317,11 @@ export function AgentChatPage() { try { const res = await fetch('/api/agent/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: msg, user_id: 1, lang: language }), + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ message: msg, lang: language }), }) const data = await res.json() const responseText = data.response || data.error || 'No response'